BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / preview.rs

preview.rs

preview.rs 67.9 KiB Download Raw
1//! CPU skeleton sampling and orbit camera for the live glTF preview.
2//!
3//! This is not the paperdoll export. The export camera is orthographic and
4//! still out of scope. The interactive camera can snap to the same five
5//! pitches and twelve yaws. The character forward axis of the test bodies is -Z,
6//! so yaw 0 places the camera on +Z (the glTF front), looking at the body.
7
8use std::path::Path;
9
10/// Free perspective view (Tim t1008u): the camera sits this much further away
11/// than `PreviewControls::distance` with a correspondingly narrower field of
12/// view, so the on-screen size stays the same with weaker perspective.
13pub const FREE_DISTANCE_FACTOR: f32 = 2.5;
14/// Original field of view of the free view at the unscaled distance.
15pub const FREE_BASE_FOV_DEG: f32 = 38.0;
16/// `2·atan(tan(38°/2) / 2.5)` ≈ 15.4°.
17pub fn free_fov_deg() -> f32 {
18 2. * ((FREE_BASE_FOV_DEG.to_radians() / 2.).tan() / FREE_DISTANCE_FACTOR)
19 .atan()
20 .to_degrees()
21}
22
23pub const PAPERDOLL_PITCHES_DEG: [f32; 5] = [-60.0, -30.0, 0.0, 30.0, 60.0];
24/// Production pitches in integer degrees, atlas column order (t955u).
25pub const PRODUCTION_PITCHES: [i32; 5] = [-60, -30, 0, 30, 60];
26/// Production views per sample: five pitches × twelve yaws.
27pub const PRODUCTION_VIEWS: u32 = 60;
28pub const PAPERDOLL_YAW_COUNT: usize = 12;
29pub const MAX_JOINTS: usize = 128;
30
31pub type Mat4 = [f32; 16];
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum PreviewSex {
35 Male,
36 Female,
37}
38
39#[derive(Debug, Clone)]
40pub struct PreviewControls {
41 pub sex: PreviewSex,
42 pub clip_name: String,
43 pub time: f32,
44 pub playing: bool,
45 pub yaw_deg: f32,
46 pub pitch_deg: f32,
47 pub distance: f32,
48}
49
50impl Default for PreviewControls {
51 fn default() -> Self {
52 Self {
53 sex: PreviewSex::Male,
54 clip_name: "walk".to_string(),
55 time: 0.0,
56 playing: true,
57 yaw_deg: 0.0,
58 pitch_deg: 0.0,
59 distance: 3.4,
60 }
61 }
62}
63
64impl PreviewControls {
65 pub fn drag(&mut self, dx_px: f32, dy_px: f32) {
66 self.yaw_deg = wrap_deg(self.yaw_deg - dx_px * 0.45);
67 self.pitch_deg = (self.pitch_deg + dy_px * 0.35).clamp(-80.0, 80.0);
68 }
69
70 pub fn zoom(&mut self, scroll_y: f32) {
71 let factor = (-scroll_y * 0.0015).exp();
72 self.distance = (self.distance * factor).clamp(0.8, 12.0);
73 }
74
75 pub fn snap_yaw_index(&mut self, index: i32) {
76 let wrapped = index.rem_euclid(PAPERDOLL_YAW_COUNT as i32) as f32;
77 self.yaw_deg = wrapped * 30.0;
78 }
79
80 pub fn snap_pitch_row(&mut self, row: i32) {
81 let row = row.clamp(0, PAPERDOLL_PITCHES_DEG.len() as i32 - 1) as usize;
82 self.pitch_deg = PAPERDOLL_PITCHES_DEG[row];
83 }
84
85 pub fn advance(&mut self, dt: f32, duration: f32) {
86 if self.playing && duration > 0.0 {
87 self.time = wrap_time(self.time + dt, duration);
88 }
89 }
90
91 pub fn select_clip(&mut self, name: &str) {
92 if self.clip_name != name {
93 self.clip_name = name.to_string();
94 self.time = 0.0;
95 }
96 }
97}
98
99#[derive(Debug, Clone)]
100pub struct PreviewCharacter {
101 pub nodes: Vec<SceneNode>,
102 pub joint_nodes: Vec<usize>,
103 pub joint_names: Vec<String>,
104 pub inverse_binds: Vec<Mat4>,
105 pub positions: Vec<[f32; 3]>,
106 pub normals: Vec<[f32; 3]>,
107 /// A placement-only fallback must never masquerade as a production normal map.
108 pub source_normals_complete: bool,
109 pub joints: Vec<[u16; 4]>,
110 pub weights: Vec<[f32; 4]>,
111 pub indices: Vec<u32>,
112 pub base_color: [f32; 4],
113 pub colors: Vec<[f32; 4]>,
114 pub uvs: Vec<[f32; 2]>,
115 pub surfaces: Vec<BodySurface>,
116 pub clips: Vec<PreviewClip>,
117 /// Skinned (skeleton + clips) or static geometry without a skeleton.
118 pub rig: BodyRig,
119}
120
121/// Whether a body has a real skin. A static body keeps an empty skeleton and
122/// no clips; it is drawn in its stored (bind/static) pose and the production
123/// batch renders the single `clip000-bind` sample. Nothing invents bones.
124#[derive(Debug, Clone, PartialEq, Eq, Default)]
125pub enum BodyRig {
126 #[default]
127 Skinned,
128 Static {
129 /// Source format label, e.g. `OBJ`, `glTF`, `FBX`.
130 format: String,
131 /// Human-readable (Dutch) explanation shown in the editor.
132 reason: String,
133 },
134}
135
136impl PreviewCharacter {
137 /// True when the body has no skin: no bones, no clips, static pose only.
138 pub fn is_static(&self) -> bool {
139 matches!(self.rig, BodyRig::Static { .. })
140 }
141 /// Editor explanation for a static body; `None` for a skinned body.
142 pub fn static_reason(&self) -> Option<&str> {
143 match &self.rig {
144 BodyRig::Static { reason, .. } => Some(reason),
145 BodyRig::Skinned => None,
146 }
147 }
148}
149
150#[derive(Debug, Clone)]
151pub struct BodySurface {
152 pub first: usize,
153 pub count: usize,
154 pub color: [f32; 4],
155 pub albedo: Option<std::sync::Arc<crate::equipment::Texture>>,
156 pub cutoff: f32,
157 pub flat_normals: bool,
158}
159
160#[derive(Debug, Clone)]
161pub struct SceneNode {
162 pub name: String,
163 pub parent: Option<usize>,
164 pub translation: [f32; 3],
165 pub rotation: [f32; 4],
166 pub scale: [f32; 3],
167}
168
169#[derive(Debug, Clone)]
170pub struct PreviewClip {
171 pub name: String,
172 pub duration: f32,
173 pub channels: Vec<AnimChannel>,
174 /// Source channels not applied, with the reason (e.g. morph weights).
175 pub skipped: Vec<String>,
176}
177
178/// Keyframe interpolation as stored in the source (glTF sampler).
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
180pub enum Interpolation {
181 #[default]
182 Linear,
183 Step,
184 /// Hermite spline; `values` holds (in-tangent, value, out-tangent) per key.
185 CubicSpline,
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum ChannelProperty {
190 Translation,
191 Rotation,
192 Scale,
193}
194
195#[derive(Debug, Clone)]
196pub struct AnimChannel {
197 pub node: usize,
198 pub property: ChannelProperty,
199 pub times: Vec<f32>,
200 pub values: Vec<f32>,
201 pub interpolation: Interpolation,
202}
203
204impl ChannelProperty {
205 pub fn width(self) -> usize {
206 match self {
207 Self::Rotation => 4,
208 _ => 3,
209 }
210 }
211}
212
213impl AnimChannel {
214 /// Times finite and non-decreasing; one value (or tangent triple) per key, finite.
215 pub fn validate(&self) -> Result<(), String> {
216 if self.times.is_empty() {
217 return Err("kanaal zonder keyframes".into());
218 }
219 if self.times.iter().any(|t| !t.is_finite() || *t < 0.)
220 || self.times.windows(2).any(|w| w[1] < w[0])
221 {
222 return Err("keyframetijden niet eindig of niet oplopend".into());
223 }
224 let per_key = self.property.width()
225 * if self.interpolation == Interpolation::CubicSpline {
226 3
227 } else {
228 1
229 };
230 if self.values.len() != self.times.len() * per_key {
231 return Err(format!(
232 "{} waarden voor {} keyframes (verwacht {per_key} per key)",
233 self.values.len(),
234 self.times.len()
235 ));
236 }
237 if self.values.iter().any(|v| !v.is_finite()) {
238 return Err("niet-eindige keyframewaarde".into());
239 }
240 Ok(())
241 }
242
243 fn value(&self, key: usize, part: usize) -> &[f32] {
244 let w = self.property.width();
245 let index = if self.interpolation == Interpolation::CubicSpline {
246 key * 3 + part
247 } else {
248 key
249 };
250 &self.values[index * w..index * w + w]
251 }
252
253 /// Sampled value at `time` (clamped to the first/last key).
254 pub fn sample(&self, time: f32) -> Vec<f32> {
255 let (left, right, blend) = key_blend(&self.times, time);
256 let w = self.property.width();
257 let a = self.value(left, 1);
258 if left == right || self.interpolation == Interpolation::Step {
259 return a.to_vec();
260 }
261 let b = self.value(right, 1);
262 match self.interpolation {
263 Interpolation::Step => a.to_vec(),
264 Interpolation::Linear => {
265 if self.property == ChannelProperty::Rotation {
266 let q = slerp([a[0], a[1], a[2], a[3]], [b[0], b[1], b[2], b[3]], blend);
267 q.to_vec()
268 } else {
269 (0..w).map(|i| a[i] + (b[i] - a[i]) * blend).collect()
270 }
271 }
272 Interpolation::CubicSpline => {
273 let dt = self.times[right] - self.times[left];
274 let out_a = self.value(left, 2);
275 let in_b = self.value(right, 0);
276 let t = blend;
277 let (t2, t3) = (t * t, t * t * t);
278 let h00 = 2. * t3 - 3. * t2 + 1.;
279 let h10 = t3 - 2. * t2 + t;
280 let h01 = -2. * t3 + 3. * t2;
281 let h11 = t3 - t2;
282 let v: Vec<f32> = (0..w)
283 .map(|i| h00 * a[i] + h10 * dt * out_a[i] + h01 * b[i] + h11 * dt * in_b[i])
284 .collect();
285 if self.property == ChannelProperty::Rotation {
286 normalize4([v[0], v[1], v[2], v[3]]).to_vec()
287 } else {
288 v
289 }
290 }
291 }
292 }
293}
294
295/// Clip for a requested name: exact match first, then the first clip whose
296/// name contains it (case-insensitive), so "walk" never picks "walk_fast"
297/// when "walk" exists.
298pub fn find_clip<'a>(clips: &'a [PreviewClip], name: &str) -> Option<&'a PreviewClip> {
299 clips.iter().find(|c| c.name == name).or_else(|| {
300 let lower = name.to_lowercase();
301 clips
302 .iter()
303 .find(|c| c.name.to_lowercase().contains(&lower))
304 })
305}
306
307/// Whether a body model path is drawable by the live viewport and production batch.
308pub fn is_drawable_body(path: &Path) -> bool {
309 matches!(
310 path.extension()
311 .and_then(|e| e.to_str())
312 .map(|e| e.to_ascii_lowercase())
313 .as_deref(),
314 Some("gltf" | "glb" | "fbx" | "obj")
315 )
316}
317
318/// Load a body for the live viewport and the production batch.
319/// glTF/GLB use the glTF reader; FBX goes through the normalized
320/// `fbx_scene` loader (axis/unit conversion is applied there exactly once).
321/// Bodies without a skin (OBJ+MTL, glTF without `skins`, FBX without a skin
322/// deformer) load as [`BodyRig::Static`]: geometry in its stored pose, no
323/// skeleton and no clips.
324pub fn load_body_file(path: &Path) -> Result<PreviewCharacter, String> {
325 match path
326 .extension()
327 .and_then(|e| e.to_str())
328 .map(|e| e.to_ascii_lowercase())
329 .as_deref()
330 {
331 Some("gltf" | "glb") => load_gltf_file(path),
332 Some("fbx") => match crate::fbx_scene::load_fbx_drawable(path) {
333 Ok(scene) => Ok(scene.character),
334 Err(error) if error == crate::fbx_scene::NO_SKINNED_MESH => load_static_rigid(
335 path,
336 "FBX",
337 "FBX zonder skin: statische rustpose, geen skeleton of animaties",
338 ),
339 Err(error) => Err(error),
340 },
341 Some("obj") => load_static_rigid(
342 path,
343 "OBJ",
344 "OBJ heeft geen skeleton of animaties: statische pose",
345 ),
346 _ => Err(format!(
347 "{} is geen tekenbaar lichaam; gebruik .glb, .gltf, .fbx of .obj",
348 path.display()
349 )),
350 }
351}
352
353/// Static OBJ/FBX body through the same rigid reader as equipment
354/// (`equipment::import_obj`/`import_fbx`): OBJ is used as stored, FBX gets
355/// the ufbx axis/unit/geometry-transform normalization once, there. The only
356/// conversion here is the UV origin (bottom-left in OBJ/FBX, top-left in
357/// `PreviewCharacter`), done exactly once.
358fn load_static_rigid(path: &Path, format: &str, reason: &str) -> Result<PreviewCharacter, String> {
359 let mut preset = crate::equipment::Preset::new("static-body".into());
360 let part = match format {
361 "OBJ" => crate::equipment::import_obj_limited(
362 &mut preset,
363 path,
364 crate::equipment::MAX_STATIC_BODY_VERTICES,
365 ),
366 _ => crate::equipment::import_fbx_limited(
367 &mut preset,
368 path,
369 crate::equipment::MAX_STATIC_BODY_VERTICES,
370 ),
371 }
372 .map_err(|error| format!("statisch {format}-lichaam: {error}"))?;
373 let mut cache = crate::equipment::TextureCache::default();
374 let mut acc = StaticAcc::default();
375 for mesh in &preset.attachments[part].meshes {
376 let albedo = mesh
377 .albedo
378 .map(|index| cache.get(&preset, index))
379 .transpose()?;
380 let uvs: Vec<[f32; 2]> = mesh.uvs.iter().map(|[u, v]| [*u, 1. - *v]).collect();
381 acc.push(
382 &mesh.positions,
383 &mesh.normals,
384 &uvs,
385 None,
386 &mesh.indices,
387 mesh.color,
388 albedo,
389 0.001,
390 false,
391 )?;
392 }
393 acc.finish(format, reason.to_string())
394}
395
396/// Accumulates static-body primitives into one `PreviewCharacter` mesh.
397#[derive(Default)]
398struct StaticAcc {
399 positions: Vec<[f32; 3]>,
400 normals: Vec<[f32; 3]>,
401 uvs: Vec<[f32; 2]>,
402 colors: Vec<[f32; 4]>,
403 indices: Vec<u32>,
404 surfaces: Vec<BodySurface>,
405 normals_complete: bool,
406 any: bool,
407}
408
409impl StaticAcc {
410 #[allow(clippy::too_many_arguments)]
411 fn push(
412 &mut self,
413 positions: &[[f32; 3]],
414 normals: &[[f32; 3]],
415 uvs: &[[f32; 2]],
416 colors: Option<&[[f32; 4]]>,
417 indices: &[u32],
418 color: [f32; 4],
419 albedo: Option<std::sync::Arc<crate::equipment::Texture>>,
420 cutoff: f32,
421 flat_normals: bool,
422 ) -> Result<(), String> {
423 if positions.is_empty() || indices.len() < 3 {
424 return Ok(());
425 }
426 if !indices.len().is_multiple_of(3)
427 || indices.iter().any(|i| *i as usize >= positions.len())
428 {
429 return Err("statisch lichaam: ongeldige driehoeksindices".into());
430 }
431 if positions
432 .iter()
433 .chain(normals.iter())
434 .any(|v| v.iter().any(|x| !x.is_finite()))
435 {
436 return Err("statisch lichaam: niet-eindige posities of normalen".into());
437 }
438 let total = self.positions.len() + positions.len();
439 if total > crate::equipment::MAX_STATIC_BODY_VERTICES {
440 return Err("statisch lichaam heeft te veel vertices".into());
441 }
442 if !self.any {
443 self.normals_complete = true;
444 self.any = true;
445 }
446 self.normals_complete &= !flat_normals && normals.len() == positions.len();
447 let base = self.positions.len() as u32;
448 let first = self.indices.len();
449 self.indices.extend(indices.iter().map(|i| i + base));
450 self.positions.extend_from_slice(positions);
451 for i in 0..positions.len() {
452 self.normals
453 .push(normals.get(i).copied().unwrap_or([0., 1., 0.]));
454 self.uvs.push(uvs.get(i).copied().unwrap_or([0., 0.]));
455 self.colors
456 .push(colors.and_then(|c| c.get(i).copied()).unwrap_or([1.; 4]));
457 }
458 self.surfaces.push(BodySurface {
459 first,
460 count: self.indices.len() - first,
461 color,
462 albedo,
463 cutoff,
464 flat_normals,
465 });
466 Ok(())
467 }
468
469 fn finish(self, format: &str, reason: String) -> Result<PreviewCharacter, String> {
470 if self.positions.is_empty() || self.indices.len() < 3 {
471 return Err(format!(
472 "statisch {format}-lichaam heeft geen tekenbare driehoeken"
473 ));
474 }
475 let count = self.positions.len();
476 Ok(PreviewCharacter {
477 nodes: Vec::new(),
478 joint_nodes: Vec::new(),
479 joint_names: Vec::new(),
480 inverse_binds: Vec::new(),
481 positions: self.positions,
482 normals: self.normals,
483 source_normals_complete: self.normals_complete,
484 // Weight 1 on slot 0 with no bones: every skinning path resolves
485 // to identity (empty bone list), i.e. the stored pose.
486 joints: vec![[0; 4]; count],
487 weights: vec![[1., 0., 0., 0.]; count],
488 indices: self.indices,
489 base_color: self
490 .surfaces
491 .first()
492 .map(|s| s.color)
493 .unwrap_or([0.7, 0.7, 0.75, 1.0]),
494 colors: self.colors,
495 uvs: self.uvs,
496 surfaces: self.surfaces,
497 clips: Vec::new(),
498 rig: BodyRig::Static {
499 format: format.to_string(),
500 reason,
501 },
502 })
503 }
504}
505
506pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
507 // The gltf crate can panic on some malformed documents; that must stay a
508 // recoverable load error for the editor and batch.
509 std::panic::catch_unwind(|| load_gltf_file_unguarded(path))
510 .unwrap_or_else(|_| Err("ongeldig glTF-bestand (parser weigerde het document)".into()))
511}
512
513/// `gltf::import` with parser panics turned into an error.
514pub fn gltf_import_guarded(
515 path: &Path,
516) -> Result<
517 (
518 gltf::Document,
519 Vec<gltf::buffer::Data>,
520 Vec<gltf::image::Data>,
521 ),
522 String,
523> {
524 std::panic::catch_unwind(|| gltf::import(path))
525 .map_err(|_| "ongeldig glTF-bestand (parser weigerde het document)".to_string())?
526 .map_err(|err| err.to_string())
527}
528
529fn load_gltf_file_unguarded(path: &Path) -> Result<PreviewCharacter, String> {
530 let (document, buffers, images) = gltf::import(path).map_err(|err| err.to_string())?;
531 let mut nodes = Vec::new();
532 let mut parents = vec![None; document.nodes().len()];
533 for node in document.nodes() {
534 for child in node.children() {
535 parents[child.index()] = Some(node.index());
536 }
537 }
538 for node in document.nodes() {
539 let (translation, rotation, scale) = node.transform().decomposed();
540 nodes.push(SceneNode {
541 name: node.name().unwrap_or("").to_string(),
542 parent: parents[node.index()],
543 translation,
544 rotation,
545 scale,
546 });
547 }
548 let Some(skin) = document.skins().next() else {
549 return load_static_gltf(&document, &buffers, &images, &nodes);
550 };
551 if skin.joints().len() > MAX_JOINTS {
552 return Err(format!(
553 "het skeleton heeft {} joints; de preview stopt bij {MAX_JOINTS}",
554 skin.joints().len()
555 ));
556 }
557 // Union of all skins' joints (first skin first); every skinned mesh node
558 // is remapped onto it, so multi-mesh / multi-skin bodies draw completely.
559 let mut joint_nodes: Vec<usize> = Vec::new();
560 let mut inverse_binds: Vec<Mat4> = Vec::new();
561 let mut skin_maps: Vec<Vec<u16>> = Vec::new();
562 for s in document.skins() {
563 let reader = s.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
564 let binds: Vec<Mat4> = reader
565 .read_inverse_bind_matrices()
566 .map(|iter| iter.map(columns_to_mat).collect())
567 .unwrap_or_else(|| vec![identity(); s.joints().len()]);
568 if binds.len() != s.joints().len() {
569 return Err("inverse-bindmatrices en joints verschillen".to_string());
570 }
571 let mut map = Vec::new();
572 for (joint, bind) in s.joints().zip(binds) {
573 let index = match joint_nodes.iter().position(|n| *n == joint.index()) {
574 Some(i) => i,
575 None => {
576 joint_nodes.push(joint.index());
577 inverse_binds.push(bind);
578 joint_nodes.len() - 1
579 }
580 };
581 map.push(index as u16);
582 }
583 skin_maps.push(map);
584 }
585 if joint_nodes.len() > MAX_JOINTS {
586 return Err(format!(
587 "het skeleton heeft {} joints; de preview stopt bij {MAX_JOINTS}",
588 joint_nodes.len()
589 ));
590 }
591 let joint_names: Vec<String> = joint_nodes
592 .iter()
593 .map(|index| nodes[*index].name.clone())
594 .collect();
595 let rest_locals: Vec<Mat4> = nodes
596 .iter()
597 .map(|n| trs(n.translation, n.rotation, n.scale))
598 .collect();
599 let rest_world = world_matrices(&nodes, &rest_locals);
600 let mut positions = Vec::new();
601 let mut normals = Vec::new();
602 let mut source_normals_complete = true;
603 let mut joints = Vec::new();
604 let mut weights = Vec::new();
605 let mut indices = Vec::new();
606 let mut base_color = [0.7, 0.7, 0.75, 1.0];
607 let mut colors = Vec::new();
608 let mut uvs = Vec::new();
609 let mut surfaces = Vec::new();
610 for node in document.nodes() {
611 let Some(mesh) = node.mesh() else { continue };
612 // Skinned: vertices are in skin space, joints remapped onto the union.
613 // Rigid (no skin): follows its nearest joint ancestor (or the first
614 // root joint), with the rest-pose node transform baked in once.
615 let rigid = match node.skin() {
616 Some(s) => Err(&skin_maps[s.index()]),
617 None => {
618 let mut current = Some(node.index());
619 let mut joint = None;
620 let mut guard = 0;
621 while let Some(n) = current {
622 if let Some(j) = joint_nodes.iter().position(|x| *x == n) {
623 joint = Some(j);
624 break;
625 }
626 current = nodes[n].parent;
627 guard += 1;
628 if guard > nodes.len() {
629 return Err("glTF-nodehiërarchie bevat een cyclus".into());
630 }
631 }
632 let j = joint.unwrap_or(0);
633 let to_joint = affine_inverse(&rest_world[joint_nodes[j]])
634 .and_then(|jr| affine_inverse(&inverse_binds[j]).map(|b| mul(b, jr)))
635 .ok_or("singuliere joint-rustmatrix")?;
636 Ok((j as u16, mul(to_joint, rest_world[node.index()])))
637 }
638 };
639 for primitive in mesh.primitives() {
640 let mut prim = read_gltf_primitive(&primitive, &buffers, &images)?;
641 let count = prim.positions.len();
642 match &rigid {
643 Err(map) => {
644 for j in prim.joints.iter_mut() {
645 for k in j.iter_mut() {
646 *k = *map.get(*k as usize).ok_or("skinjoint buiten skin")?;
647 }
648 }
649 }
650 Ok((joint, m)) => {
651 let nm =
652 normal_matrix(m).ok_or("glTF-node heeft een singuliere transformatie")?;
653 for p in prim.positions.iter_mut() {
654 *p = transform_point3(m, *p);
655 }
656 if let Some(ns) = prim.normals.as_mut() {
657 for n in ns.iter_mut() {
658 *n = normalize3(mat3_mul(&nm, *n));
659 }
660 }
661 if det3(m) < 0. {
662 for tri in prim.indices.chunks_exact_mut(3) {
663 tri.swap(1, 2);
664 }
665 }
666 prim.joints = vec![[*joint, 0, 0, 0]; count];
667 prim.weights = vec![[1., 0., 0., 0.]; count];
668 }
669 }
670 source_normals_complete &= prim.normals.is_some();
671 let first = indices.len();
672 let base = positions.len() as u32;
673 indices.extend(prim.indices.iter().map(|index| index + base));
674 positions.extend(prim.positions);
675 normals.extend(prim.normals.unwrap_or_else(|| vec![[0.0, 1.0, 0.0]; count]));
676 joints.extend(prim.joints);
677 weights.extend(prim.weights);
678 colors.extend(prim.colors);
679 uvs.extend(prim.uvs);
680 base_color = prim.color;
681 surfaces.push(BodySurface {
682 flat_normals: prim.flat_normals,
683 first,
684 count: indices.len() - first,
685 color: prim.color,
686 albedo: prim.albedo,
687 cutoff: prim.cutoff,
688 });
689 }
690 }
691 if positions.is_empty() || indices.len() < 3 {
692 return Err("het glTF-bestand heeft geen tekenbare driehoeken".to_string());
693 }
694 if positions
695 .iter()
696 .chain(&normals)
697 .flatten()
698 .any(|v| !v.is_finite())
699 {
700 return Err("glTF-mesh heeft niet-eindige posities na de node-transformatie".into());
701 }
702 let mut clips = Vec::new();
703 for animation in document.animations() {
704 let mut channels = Vec::new();
705 let mut skipped = Vec::new();
706 let mut duration = 0.0_f32;
707 for channel in animation.channels() {
708 let target = channel.target().node().index();
709 let interpolation = match channel.sampler().interpolation() {
710 gltf::animation::Interpolation::Linear => Interpolation::Linear,
711 gltf::animation::Interpolation::Step => Interpolation::Step,
712 gltf::animation::Interpolation::CubicSpline => Interpolation::CubicSpline,
713 };
714 let reader = channel.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
715 let times: Vec<f32> = reader
716 .read_inputs()
717 .map(|iter| iter.collect())
718 .unwrap_or_default();
719 let Some(outputs) = reader.read_outputs() else {
720 skipped.push(format!("node {target}: geen uitvoerwaarden"));
721 continue;
722 };
723 let (property, values) = match outputs {
724 gltf::animation::util::ReadOutputs::Translations(values) => {
725 (ChannelProperty::Translation, values.flatten().collect())
726 }
727 gltf::animation::util::ReadOutputs::Scales(values) => {
728 (ChannelProperty::Scale, values.flatten().collect())
729 }
730 gltf::animation::util::ReadOutputs::Rotations(values) => (
731 ChannelProperty::Rotation,
732 values.into_f32().flatten().collect(),
733 ),
734 gltf::animation::util::ReadOutputs::MorphTargetWeights(_) => {
735 skipped.push(format!(
736 "node {target}: morph-target-gewichten (niet ondersteund)"
737 ));
738 continue;
739 }
740 };
741 let channel = AnimChannel {
742 node: target,
743 property,
744 times,
745 values,
746 interpolation,
747 };
748 if let Err(reason) = channel.validate() {
749 return Err(format!(
750 "animatie '{}': ongeldig kanaal op node {target}: {reason}",
751 animation.name().unwrap_or("naamloos")
752 ));
753 }
754 if let Some(last) = channel.times.last() {
755 duration = duration.max(*last);
756 }
757 channels.push(channel);
758 }
759 clips.push(PreviewClip {
760 name: animation
761 .name()
762 .filter(|n| !n.trim().is_empty())
763 .map(str::to_string)
764 .unwrap_or_else(|| format!("animatie {}", animation.index())),
765 duration,
766 channels,
767 skipped,
768 });
769 }
770 Ok(PreviewCharacter {
771 nodes,
772 joint_nodes,
773 joint_names,
774 inverse_binds,
775 positions,
776 normals,
777 source_normals_complete,
778 joints,
779 weights,
780 indices,
781 base_color,
782 colors,
783 uvs,
784 surfaces,
785 clips,
786 rig: BodyRig::Skinned,
787 })
788}
789
790/// One glTF triangle primitive read in its mesh-local space.
791struct GltfPrimitive {
792 positions: Vec<[f32; 3]>,
793 normals: Option<Vec<[f32; 3]>>,
794 joints: Vec<[u16; 4]>,
795 weights: Vec<[f32; 4]>,
796 colors: Vec<[f32; 4]>,
797 uvs: Vec<[f32; 2]>,
798 indices: Vec<u32>,
799 color: [f32; 4],
800 albedo: Option<std::sync::Arc<crate::equipment::Texture>>,
801 cutoff: f32,
802 flat_normals: bool,
803}
804
805fn read_gltf_primitive(
806 primitive: &gltf::Primitive<'_>,
807 buffers: &[gltf::buffer::Data],
808 images: &[gltf::image::Data],
809) -> Result<GltfPrimitive, String> {
810 if primitive.mode() != gltf::mesh::Mode::Triangles {
811 return Err("live/productie ondersteunt alleen driehoekprimitieven".into());
812 }
813 let reader = primitive.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
814 let positions: Vec<[f32; 3]> = reader
815 .read_positions()
816 .map(|iter| iter.collect())
817 .unwrap_or_default();
818 let normals: Option<Vec<[f32; 3]>> = reader.read_normals().map(|iter| iter.collect());
819 let joints: Vec<[u16; 4]> = reader
820 .read_joints(0)
821 .map(|iter| iter.into_u16().collect())
822 .unwrap_or_else(|| vec![[0, 0, 0, 0]; positions.len()]);
823 let weights: Vec<[f32; 4]> = reader
824 .read_weights(0)
825 .map(|iter| iter.into_f32().collect())
826 .unwrap_or_else(|| vec![[1.0, 0.0, 0.0, 0.0]; positions.len()]);
827 if positions.iter().flatten().any(|v| !v.is_finite()) {
828 return Err("glTF-primitive heeft niet-eindige posities".into());
829 }
830 if normals
831 .as_ref()
832 .is_some_and(|n| n.len() != positions.len() || n.iter().flatten().any(|v| !v.is_finite()))
833 {
834 return Err("glTF-primitive heeft ongeldige normalen".into());
835 }
836 if joints.len() != positions.len()
837 || weights.len() != positions.len()
838 || weights.iter().flatten().any(|w| !w.is_finite() || *w < 0.)
839 {
840 return Err("glTF-primitive heeft ongeldige joints/gewichten".into());
841 }
842 let material = primitive.material();
843 let pbr = material.pbr_metallic_roughness();
844 let texture = pbr.base_color_texture();
845 let transform = texture.as_ref().and_then(|t| t.texture_transform());
846 let uvset = transform
847 .as_ref()
848 .and_then(|t| t.tex_coord())
849 .or_else(|| texture.as_ref().map(|t| t.tex_coord()))
850 .unwrap_or(0);
851 let colors = reader
852 .read_colors(0)
853 .map(|v| v.into_rgba_f32().collect::<Vec<_>>())
854 .unwrap_or_else(|| vec![[1.; 4]; positions.len()]);
855 let uvs = reader
856 .read_tex_coords(uvset)
857 .map(|v| v.into_f32().collect::<Vec<_>>())
858 .unwrap_or_else(|| vec![[0.; 2]; positions.len()]);
859 let uvs = match &transform {
860 Some(t) => {
861 let m = uv_transform(t.offset(), t.rotation(), t.scale());
862 uvs.into_iter().map(|uv| apply_uv(&m, uv)).collect()
863 }
864 None => uvs,
865 };
866 let indices: Vec<u32> = match reader.read_indices() {
867 Some(read_indices) => read_indices.into_u32().collect(),
868 None => (0..positions.len() as u32).collect(),
869 };
870 if indices.iter().any(|i| *i as usize >= positions.len()) {
871 return Err("glTF-primitive heeft indices buiten het vertexbereik".into());
872 }
873 let albedo = texture
874 .map(|t| {
875 let image = images
876 .get(t.texture().source().index())
877 .ok_or("ontbrekende bodytexture")?;
878 use gltf::image::Format;
879 let channels = match image.format {
880 Format::R8 => 1,
881 Format::R8G8 => 2,
882 Format::R8G8B8 => 3,
883 Format::R8G8B8A8 => 4,
884 _ => return Err("bodytexture moet 8-bit zijn".to_string()),
885 };
886 if image.width > 2048 || image.height > 2048 {
887 return Err("bodytexture groter dan 2048×2048".into());
888 }
889 let mut rgba = Vec::new();
890 for p in image.pixels.chunks_exact(channels) {
891 rgba.extend_from_slice(&match channels {
892 1 => [p[0], p[0], p[0], 255],
893 2 => [p[0], p[0], p[0], p[1]],
894 3 => [p[0], p[1], p[2], 255],
895 _ => [p[0], p[1], p[2], p[3]],
896 });
897 }
898 use std::hash::{Hash, Hasher};
899 let mut hash = std::collections::hash_map::DefaultHasher::new();
900 rgba.hash(&mut hash);
901 Ok(std::sync::Arc::new(crate::equipment::Texture {
902 content_hash: hash.finish(),
903 width: image.width,
904 height: image.height,
905 rgba,
906 }))
907 })
908 .transpose()?;
909 Ok(GltfPrimitive {
910 flat_normals: normals.is_none(),
911 positions,
912 normals,
913 joints,
914 weights,
915 colors,
916 uvs,
917 indices,
918 color: pbr.base_color_factor(),
919 albedo,
920 cutoff: if material.alpha_mode() == gltf::material::AlphaMode::Mask {
921 material.alpha_cutoff().unwrap_or(0.5)
922 } else {
923 0.001
924 },
925 })
926}
927
928/// glTF without a skin: every mesh instance of the default scene, with its
929/// node's rest world transform baked into positions (and the inverse-
930/// transpose into normals) exactly once; mirrored transforms flip winding.
931/// Animations are not applied (no skeleton is invented).
932fn load_static_gltf(
933 document: &gltf::Document,
934 buffers: &[gltf::buffer::Data],
935 images: &[gltf::image::Data],
936 nodes: &[SceneNode],
937) -> Result<PreviewCharacter, String> {
938 let mut roots: Vec<usize> = match document
939 .default_scene()
940 .or_else(|| document.scenes().next())
941 {
942 Some(scene) => scene.nodes().map(|n| n.index()).collect(),
943 None => (0..nodes.len())
944 .filter(|i| nodes[*i].parent.is_none())
945 .collect(),
946 };
947 let children: Vec<Vec<usize>> = document
948 .nodes()
949 .map(|n| n.children().map(|c| c.index()).collect())
950 .collect();
951 let mut acc = StaticAcc::default();
952 let mut stack: Vec<(usize, Mat4, usize)> =
953 roots.drain(..).rev().map(|r| (r, identity(), 0)).collect();
954 let mut visited = 0usize;
955 while let Some((index, parent, depth)) = stack.pop() {
956 visited += 1;
957 if depth > nodes.len() || visited > nodes.len() * 4 + 16 {
958 return Err("glTF-nodehiërarchie bevat een cyclus".into());
959 }
960 let node = nodes.get(index).ok_or("ongeldige glTF-node")?;
961 let world = mul(parent, trs(node.translation, node.rotation, node.scale));
962 if !world.iter().all(|x| x.is_finite()) {
963 return Err("glTF-node heeft een niet-eindige transformatie".into());
964 }
965 if let Some(mesh) = document.nodes().nth(index).and_then(|n| n.mesh()) {
966 let normal_matrix =
967 normal_matrix(&world).ok_or("glTF-node heeft een singuliere transformatie")?;
968 let mirrored = det3(&world) < 0.0;
969 for primitive in mesh.primitives() {
970 let prim = read_gltf_primitive(&primitive, buffers, images)?;
971 let positions: Vec<[f32; 3]> = prim
972 .positions
973 .iter()
974 .map(|p| transform_point3(&world, *p))
975 .collect();
976 let normals: Vec<[f32; 3]> = match &prim.normals {
977 Some(normals) => normals
978 .iter()
979 .map(|n| normalize3(mat3_mul(&normal_matrix, *n)))
980 .collect(),
981 None => vec![[0., 1., 0.]; positions.len()],
982 };
983 let mut indices = prim.indices.clone();
984 if !indices.len().is_multiple_of(3) {
985 return Err("glTF-primitive heeft een onvolledige driehoek".into());
986 }
987 if mirrored {
988 for tri in indices.chunks_exact_mut(3) {
989 tri.swap(1, 2);
990 }
991 }
992 acc.push(
993 &positions,
994 &normals,
995 &prim.uvs,
996 Some(&prim.colors),
997 &indices,
998 prim.color,
999 prim.albedo,
1000 prim.cutoff,
1001 prim.flat_normals,
1002 )?;
1003 }
1004 }
1005 for child in children.get(index).into_iter().flatten().rev() {
1006 stack.push((*child, world, depth + 1));
1007 }
1008 }
1009 let animations = document.animations().count();
1010 let reason = if animations == 0 {
1011 "glTF zonder skin: statische pose, geen skeleton of animaties".to_string()
1012 } else {
1013 format!(
1014 "glTF zonder skin: statische pose; {animations} animatie(s) niet toegepast (geen skeleton)"
1015 )
1016 };
1017 acc.finish("glTF", reason)
1018}
1019
1020/// KHR_texture_transform as a 2×3 affine UV matrix (T·R·S, rotation
1021/// counter-clockwise in UV space as in the extension's examples).
1022pub fn uv_transform(offset: [f32; 2], rotation: f32, scale: [f32; 2]) -> [[f32; 3]; 2] {
1023 let (s, c) = rotation.sin_cos();
1024 [
1025 [c * scale[0], s * scale[1], offset[0]],
1026 [-s * scale[0], c * scale[1], offset[1]],
1027 ]
1028}
1029
1030fn apply_uv(m: &[[f32; 3]; 2], uv: [f32; 2]) -> [f32; 2] {
1031 [
1032 m[0][0] * uv[0] + m[0][1] * uv[1] + m[0][2],
1033 m[1][0] * uv[0] + m[1][1] * uv[1] + m[1][2],
1034 ]
1035}
1036
1037/// Inverse of an affine matrix (None when singular).
1038fn affine_inverse(m: &Mat4) -> Option<Mat4> {
1039 let it = normal_matrix(m)?; // inverse-transpose of the 3×3
1040 // inverse 3×3 (row r, col c) = it[c][r]
1041 let mut out = identity();
1042 for r in 0..3 {
1043 for c in 0..3 {
1044 out[c * 4 + r] = it[c][r];
1045 }
1046 }
1047 let t = [m[12], m[13], m[14]];
1048 for r in 0..3 {
1049 out[12 + r] = -(0..3).map(|c| out[c * 4 + r] * t[c]).sum::<f32>();
1050 }
1051 Some(out)
1052}
1053
1054fn transform_point3(m: &Mat4, p: [f32; 3]) -> [f32; 3] {
1055 [
1056 m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
1057 m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
1058 m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14],
1059 ]
1060}
1061
1062fn det3(m: &Mat4) -> f32 {
1063 m[0] * (m[5] * m[10] - m[9] * m[6]) - m[4] * (m[1] * m[10] - m[9] * m[2])
1064 + m[8] * (m[1] * m[6] - m[5] * m[2])
1065}
1066
1067/// Inverse-transpose of the upper 3×3 (row-major result for `mat3_mul`).
1068fn normal_matrix(m: &Mat4) -> Option<[[f32; 3]; 3]> {
1069 let det = det3(m);
1070 if !det.is_finite() || det.abs() < 1e-12 {
1071 return None;
1072 }
1073 // a[r][c] = element at row r, column c of the column-major matrix.
1074 let a = |r: usize, c: usize| m[c * 4 + r];
1075 let cof = |r: usize, c: usize| {
1076 let rows: Vec<usize> = (0..3).filter(|x| *x != r).collect();
1077 let cols: Vec<usize> = (0..3).filter(|x| *x != c).collect();
1078 let minor =
1079 a(rows[0], cols[0]) * a(rows[1], cols[1]) - a(rows[0], cols[1]) * a(rows[1], cols[0]);
1080 if (r + c).is_multiple_of(2) {
1081 minor
1082 } else {
1083 -minor
1084 }
1085 };
1086 // inverse-transpose = cofactor matrix / det.
1087 let mut out = [[0.; 3]; 3];
1088 for (r, row) in out.iter_mut().enumerate() {
1089 for (c, value) in row.iter_mut().enumerate() {
1090 *value = cof(r, c) / det;
1091 }
1092 }
1093 Some(out)
1094}
1095
1096fn mat3_mul(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
1097 [
1098 m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
1099 m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
1100 m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
1101 ]
1102}
1103
1104pub fn sample_skin_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {
1105 let locals = sample_local_matrices(character, clip_name, time);
1106 let worlds = world_matrices(&character.nodes, &locals);
1107 character
1108 .joint_nodes
1109 .iter()
1110 .enumerate()
1111 .map(|(joint, node)| mul(worlds[*node], character.inverse_binds[joint]))
1112 .collect()
1113}
1114
1115/// Stable hierarchy path includes node indices to disambiguate duplicate names.
1116pub fn joint_paths(character: &PreviewCharacter) -> Vec<String> {
1117 character
1118 .joint_nodes
1119 .iter()
1120 .map(|&node| {
1121 let mut names = Vec::new();
1122 let mut cursor = Some(node);
1123 while let Some(index) = cursor {
1124 let item = &character.nodes[index];
1125 names.push(format!("{}[{}]", item.name, index));
1126 cursor = item.parent;
1127 if names.len() > character.nodes.len() {
1128 break;
1129 }
1130 }
1131 names.reverse();
1132 names.join("/")
1133 })
1134 .collect()
1135}
1136
1137/// Rest-world joint transform, never selected by a possibly empty clip name.
1138/// Bounded parent walk rejects malformed cycles/indices instead of recursing.
1139pub fn rest_joint_world(character: &PreviewCharacter, joint: usize) -> Option<Mat4> {
1140 let mut node = Some(*character.joint_nodes.get(joint)?);
1141 let mut chain = Vec::new();
1142 while let Some(i) = node {
1143 if chain.len() >= character.nodes.len() {
1144 return None;
1145 }
1146 let n = character.nodes.get(i)?;
1147 chain.push(i);
1148 node = n.parent;
1149 }
1150 let mut world = identity();
1151 for i in chain.into_iter().rev() {
1152 let n = &character.nodes[i];
1153 world = mul(world, trs(n.translation, n.rotation, n.scale));
1154 }
1155 world.iter().all(|n| n.is_finite()).then_some(world)
1156}
1157
1158pub fn animated_joint_world(
1159 character: &PreviewCharacter,
1160 clip: &str,
1161 time: f32,
1162 joint: usize,
1163) -> Option<Mat4> {
1164 let locals = sample_local_matrices(character, clip, time);
1165 let worlds = world_matrices(&character.nodes, &locals);
1166 character
1167 .joint_nodes
1168 .get(joint)
1169 .and_then(|node| worlds.get(*node))
1170 .copied()
1171}
1172
1173pub fn joint_world_translation(
1174 character: &PreviewCharacter,
1175 clip_name: &str,
1176 time: f32,
1177 joint_name: &str,
1178) -> Option<[f32; 3]> {
1179 let joint = character
1180 .joint_names
1181 .iter()
1182 .position(|name| name == joint_name)?;
1183 let node = character.joint_nodes[joint];
1184 let locals = sample_local_matrices(character, clip_name, time);
1185 let worlds = world_matrices(&character.nodes, &locals);
1186 Some([worlds[node][12], worlds[node][13], worlds[node][14]])
1187}
1188
1189pub fn clip_duration(character: &PreviewCharacter, clip_name: &str) -> f32 {
1190 character
1191 .clips
1192 .iter()
1193 .find(|clip| {
1194 clip.name == clip_name || clip.name.to_lowercase().contains(&clip_name.to_lowercase())
1195 })
1196 .map(|clip| clip.duration)
1197 .unwrap_or(0.0)
1198}
1199
1200pub fn eye_offset(yaw_deg: f32, pitch_deg: f32, distance: f32) -> [f32; 3] {
1201 let yaw = yaw_deg.to_radians();
1202 let pitch = pitch_deg.to_radians();
1203 [
1204 distance * pitch.cos() * yaw.sin(),
1205 distance * pitch.sin(),
1206 distance * pitch.cos() * yaw.cos(),
1207 ]
1208}
1209
1210pub fn paperdoll_yaws_deg() -> [f32; PAPERDOLL_YAW_COUNT] {
1211 let mut yaws = [0.0; PAPERDOLL_YAW_COUNT];
1212 for (index, yaw) in yaws.iter_mut().enumerate() {
1213 *yaw = index as f32 * 30.0;
1214 }
1215 yaws
1216}
1217
1218fn sample_local_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {
1219 let mut translation: Vec<[f32; 3]> = character
1220 .nodes
1221 .iter()
1222 .map(|node| node.translation)
1223 .collect();
1224 let mut rotation: Vec<[f32; 4]> = character.nodes.iter().map(|node| node.rotation).collect();
1225 let mut scale: Vec<[f32; 3]> = character.nodes.iter().map(|node| node.scale).collect();
1226 if let Some(clip) = find_clip(&character.clips, clip_name) {
1227 let time = wrap_time(time, clip.duration);
1228 for channel in &clip.channels {
1229 if channel.node >= character.nodes.len() || channel.validate().is_err() {
1230 continue;
1231 }
1232 let v = channel.sample(time);
1233 match channel.property {
1234 ChannelProperty::Translation => translation[channel.node] = [v[0], v[1], v[2]],
1235 ChannelProperty::Scale => scale[channel.node] = [v[0], v[1], v[2]],
1236 ChannelProperty::Rotation => rotation[channel.node] = [v[0], v[1], v[2], v[3]],
1237 }
1238 }
1239 }
1240 translation
1241 .into_iter()
1242 .zip(rotation)
1243 .zip(scale)
1244 .map(|((translation, rotation), scale)| trs(translation, rotation, scale))
1245 .collect()
1246}
1247
1248fn world_matrices(nodes: &[SceneNode], locals: &[Mat4]) -> Vec<Mat4> {
1249 fn world(
1250 index: usize,
1251 nodes: &[SceneNode],
1252 locals: &[Mat4],
1253 worlds: &mut [Option<Mat4>],
1254 depth: usize,
1255 ) -> Mat4 {
1256 if let Some(matrix) = worlds[index] {
1257 return matrix;
1258 }
1259 if depth > nodes.len() {
1260 return locals[index];
1261 }
1262 let matrix = match nodes[index].parent {
1263 Some(parent) => mul(
1264 world(parent, nodes, locals, worlds, depth + 1),
1265 locals[index],
1266 ),
1267 None => locals[index],
1268 };
1269 worlds[index] = Some(matrix);
1270 matrix
1271 }
1272 let mut cached = vec![None; nodes.len()];
1273 (0..nodes.len())
1274 .map(|index| world(index, nodes, locals, &mut cached, 0))
1275 .collect()
1276}
1277
1278fn key_blend(times: &[f32], time: f32) -> (usize, usize, f32) {
1279 if times.len() == 1 || time <= times[0] {
1280 return (0, 0, 0.0);
1281 }
1282 if time >= times[times.len() - 1] {
1283 let last = times.len() - 1;
1284 return (last, last, 0.0);
1285 }
1286 let mut index = 0;
1287 while index + 1 < times.len() && times[index + 1] < time {
1288 index += 1;
1289 }
1290 let span = times[index + 1] - times[index];
1291 let blend = if span.abs() < 1e-6 {
1292 0.0
1293 } else {
1294 (time - times[index]) / span
1295 };
1296 (index, index + 1, blend)
1297}
1298
1299fn slerp(a: [f32; 4], mut b: [f32; 4], t: f32) -> [f32; 4] {
1300 let mut dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
1301 if dot < 0.0 {
1302 dot = -dot;
1303 b = [-b[0], -b[1], -b[2], -b[3]];
1304 }
1305 if dot > 0.9995 {
1306 return normalize4([
1307 a[0] + (b[0] - a[0]) * t,
1308 a[1] + (b[1] - a[1]) * t,
1309 a[2] + (b[2] - a[2]) * t,
1310 a[3] + (b[3] - a[3]) * t,
1311 ]);
1312 }
1313 let theta = dot.clamp(-1.0, 1.0).acos();
1314 let sin_theta = theta.sin();
1315 let w1 = ((1.0 - t) * theta).sin() / sin_theta;
1316 let w2 = (t * theta).sin() / sin_theta;
1317 [
1318 a[0] * w1 + b[0] * w2,
1319 a[1] * w1 + b[1] * w2,
1320 a[2] * w1 + b[2] * w2,
1321 a[3] * w1 + b[3] * w2,
1322 ]
1323}
1324
1325fn normalize4(value: [f32; 4]) -> [f32; 4] {
1326 let length =
1327 (value[0] * value[0] + value[1] * value[1] + value[2] * value[2] + value[3] * value[3])
1328 .sqrt();
1329 if length < 1e-8 {
1330 [0.0, 0.0, 0.0, 1.0]
1331 } else {
1332 [
1333 value[0] / length,
1334 value[1] / length,
1335 value[2] / length,
1336 value[3] / length,
1337 ]
1338 }
1339}
1340
1341fn trs(translation: [f32; 3], rotation: [f32; 4], scale: [f32; 3]) -> Mat4 {
1342 let mut matrix = quat_to_mat(normalize4(rotation));
1343 matrix[0] *= scale[0];
1344 matrix[1] *= scale[0];
1345 matrix[2] *= scale[0];
1346 matrix[4] *= scale[1];
1347 matrix[5] *= scale[1];
1348 matrix[6] *= scale[1];
1349 matrix[8] *= scale[2];
1350 matrix[9] *= scale[2];
1351 matrix[10] *= scale[2];
1352 matrix[12] = translation[0];
1353 matrix[13] = translation[1];
1354 matrix[14] = translation[2];
1355 matrix
1356}
1357
1358fn quat_to_mat(q: [f32; 4]) -> Mat4 {
1359 let (x, y, z, w) = (q[0], q[1], q[2], q[3]);
1360 let xx = x * x;
1361 let yy = y * y;
1362 let zz = z * z;
1363 let xy = x * y;
1364 let xz = x * z;
1365 let yz = y * z;
1366 let wx = w * x;
1367 let wy = w * y;
1368 let wz = w * z;
1369 [
1370 1.0 - 2.0 * (yy + zz),
1371 2.0 * (xy + wz),
1372 2.0 * (xz - wy),
1373 0.0,
1374 2.0 * (xy - wz),
1375 1.0 - 2.0 * (xx + zz),
1376 2.0 * (yz + wx),
1377 0.0,
1378 2.0 * (xz + wy),
1379 2.0 * (yz - wx),
1380 1.0 - 2.0 * (xx + yy),
1381 0.0,
1382 0.0,
1383 0.0,
1384 0.0,
1385 1.0,
1386 ]
1387}
1388
1389pub fn identity() -> Mat4 {
1390 [
1391 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,
1392 ]
1393}
1394
1395pub fn mul(left: Mat4, right: Mat4) -> Mat4 {
1396 let mut out = [0.0; 16];
1397 for column in 0..4 {
1398 for row in 0..4 {
1399 out[column * 4 + row] = left[row] * right[column * 4]
1400 + left[4 + row] * right[column * 4 + 1]
1401 + left[8 + row] * right[column * 4 + 2]
1402 + left[12 + row] * right[column * 4 + 3];
1403 }
1404 }
1405 out
1406}
1407
1408fn columns_to_mat(columns: [[f32; 4]; 4]) -> Mat4 {
1409 let mut matrix = [0.0; 16];
1410 for column in 0..4 {
1411 for row in 0..4 {
1412 matrix[column * 4 + row] = columns[column][row];
1413 }
1414 }
1415 matrix
1416}
1417
1418pub fn wrap_time(time: f32, duration: f32) -> f32 {
1419 if duration <= 1e-5 {
1420 return 0.0;
1421 }
1422 let mut wrapped = time % duration;
1423 if wrapped < 0.0 {
1424 wrapped += duration;
1425 }
1426 wrapped
1427}
1428
1429fn wrap_deg(mut degrees: f32) -> f32 {
1430 degrees %= 360.0;
1431 if degrees < 0.0 {
1432 degrees += 360.0;
1433 }
1434 degrees
1435}
1436
1437pub fn view_matrix(eye: [f32; 3], target: [f32; 3]) -> Mat4 {
1438 let forward = normalize3([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]);
1439 let up = [0.0, 1.0, 0.0];
1440 let mut side = normalize3(cross(forward, up));
1441 if side[0].abs() + side[1].abs() + side[2].abs() < 1e-4 {
1442 side = [1.0, 0.0, 0.0];
1443 }
1444 let up = cross(side, forward);
1445 [
1446 side[0],
1447 up[0],
1448 -forward[0],
1449 0.0,
1450 side[1],
1451 up[1],
1452 -forward[1],
1453 0.0,
1454 side[2],
1455 up[2],
1456 -forward[2],
1457 0.0,
1458 -dot(side, eye),
1459 -dot(up, eye),
1460 dot(forward, eye),
1461 1.0,
1462 ]
1463}
1464
1465pub fn perspective(fov_y_deg: f32, aspect: f32, near: f32, far: f32) -> Mat4 {
1466 let f = 1.0 / (fov_y_deg.to_radians() * 0.5).tan();
1467 let aspect = if aspect.abs() < 1e-4 { 1.0 } else { aspect };
1468 let nf = 1.0 / (near - far);
1469 [
1470 f / aspect,
1471 0.0,
1472 0.0,
1473 0.0,
1474 0.0,
1475 f,
1476 0.0,
1477 0.0,
1478 0.0,
1479 0.0,
1480 (far + near) * nf,
1481 -1.0,
1482 0.0,
1483 0.0,
1484 2.0 * far * near * nf,
1485 0.0,
1486 ]
1487}
1488
1489fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
1490 [
1491 a[1] * b[2] - a[2] * b[1],
1492 a[2] * b[0] - a[0] * b[2],
1493 a[0] * b[1] - a[1] * b[0],
1494 ]
1495}
1496
1497fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
1498 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
1499}
1500
1501fn normalize3(value: [f32; 3]) -> [f32; 3] {
1502 let length = dot(value, value).sqrt();
1503 if length < 1e-8 {
1504 [0.0, 0.0, 1.0]
1505 } else {
1506 [value[0] / length, value[1] / length, value[2] / length]
1507 }
1508}
1509
1510#[cfg(test)]
1511mod tests {
1512 use super::*;
1513
1514 fn chan(
1515 property: ChannelProperty,
1516 interpolation: Interpolation,
1517 times: &[f32],
1518 values: &[f32],
1519 ) -> AnimChannel {
1520 AnimChannel {
1521 node: 0,
1522 property,
1523 times: times.to_vec(),
1524 values: values.to_vec(),
1525 interpolation,
1526 }
1527 }
1528
1529 #[test]
1530 fn step_linear_and_cubic_interpolation_follow_gltf() {
1531 let lin = chan(
1532 ChannelProperty::Translation,
1533 Interpolation::Linear,
1534 &[0., 1.],
1535 &[0., 0., 0., 2., 4., 6.],
1536 );
1537 assert_eq!(lin.sample(0.25), vec![0.5, 1., 1.5]);
1538 assert_eq!(lin.sample(-1.), vec![0., 0., 0.]);
1539 assert_eq!(lin.sample(5.), vec![2., 4., 6.]);
1540 let step = Interpolation::Step;
1541 let st = chan(
1542 ChannelProperty::Translation,
1543 step,
1544 &[0., 1.],
1545 &[0., 0., 0., 2., 4., 6.],
1546 );
1547 assert_eq!(st.sample(0.99), vec![0., 0., 0.]);
1548 assert_eq!(st.sample(1.), vec![2., 4., 6.]);
1549 // Cubic: (in, value, out) per key; zero tangents give smoothstep.
1550 let cubic = chan(
1551 ChannelProperty::Translation,
1552 Interpolation::CubicSpline,
1553 &[0., 2.],
1554 &[
1555 9., 9., 9., 0., 0., 0., 0., 0., 0., 0., 0., 0., 10., 10., 10., 9., 9., 9.,
1556 ],
1557 );
1558 cubic.validate().unwrap();
1559 let v = cubic.sample(0.5); // t = 0.25 -> h01 = 0.15625
1560 assert!((v[0] - 1.5625).abs() < 1e-5, "{v:?}");
1561 // Tangents are scaled by the key interval: out-tangent 1/s over 2 s.
1562 let sloped = chan(
1563 ChannelProperty::Translation,
1564 Interpolation::CubicSpline,
1565 &[0., 2.],
1566 &[
1567 0., 0., 0., 0., 0., 0., 1., 0., 0., 0., 0., 0., 2., 0., 0., 0., 0., 0.,
1568 ],
1569 );
1570 let v = sloped.sample(1.); // t=.5: h01*2 + h10*2*1 = 1 + 0.25
1571 assert!((v[0] - 1.25).abs() < 1e-5, "{v:?}");
1572 // Cubic rotation stays unit length.
1573 let q = chan(
1574 ChannelProperty::Rotation,
1575 Interpolation::CubicSpline,
1576 &[0., 1.],
1577 &[
1578 0.,
1579 0.,
1580 0.,
1581 0.,
1582 0.,
1583 0.,
1584 0.,
1585 1.,
1586 0.,
1587 0.,
1588 0.,
1589 0.,
1590 0.,
1591 0.,
1592 0.,
1593 0.,
1594 0.,
1595 0.,
1596 std::f32::consts::FRAC_1_SQRT_2,
1597 std::f32::consts::FRAC_1_SQRT_2,
1598 0.,
1599 0.,
1600 0.,
1601 0.,
1602 ],
1603 );
1604 let r = q.sample(0.5);
1605 let len: f32 = r.iter().map(|x| x * x).sum::<f32>().sqrt();
1606 assert!((len - 1.).abs() < 1e-4);
1607 // Validation rejects malformed channels.
1608 assert!(chan(
1609 ChannelProperty::Translation,
1610 Interpolation::CubicSpline,
1611 &[0., 1.],
1612 &[0.; 6]
1613 )
1614 .validate()
1615 .is_err());
1616 assert!(chan(
1617 ChannelProperty::Translation,
1618 Interpolation::Linear,
1619 &[1., 0.],
1620 &[0.; 6]
1621 )
1622 .validate()
1623 .is_err());
1624 assert!(chan(
1625 ChannelProperty::Translation,
1626 Interpolation::Linear,
1627 &[0., f32::NAN],
1628 &[0.; 6]
1629 )
1630 .validate()
1631 .is_err());
1632 assert!(chan(
1633 ChannelProperty::Scale,
1634 Interpolation::Linear,
1635 &[0.],
1636 &[f32::INFINITY, 0., 0.]
1637 )
1638 .validate()
1639 .is_err());
1640 }
1641
1642 #[test]
1643 fn clip_lookup_prefers_exact_name() {
1644 let clip = |name: &str| PreviewClip {
1645 name: name.into(),
1646 duration: 1.,
1647 channels: Vec::new(),
1648 skipped: Vec::new(),
1649 };
1650 let clips = vec![clip("walk_fast"), clip("walk"), clip("Attack")];
1651 assert_eq!(find_clip(&clips, "walk").unwrap().name, "walk");
1652 assert_eq!(find_clip(&clips, "attack").unwrap().name, "Attack");
1653 assert!(find_clip(&clips, "run").is_none());
1654 }
1655
1656 fn glb_with(edit: impl Fn(&mut serde_json::Value)) -> std::path::PathBuf {
1657 let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1658 .join("tests/fixtures/bodies/test-body-male.glb");
1659 let bytes = std::fs::read(src).unwrap();
1660 let json_len = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize;
1661 let mut doc: serde_json::Value = serde_json::from_slice(&bytes[20..20 + json_len]).unwrap();
1662 edit(&mut doc);
1663 let mut json = serde_json::to_vec(&doc).unwrap();
1664 while !json.len().is_multiple_of(4) {
1665 json.push(b' ');
1666 }
1667 let rest = &bytes[20 + json_len..];
1668 let mut out = Vec::new();
1669 out.extend_from_slice(&bytes[0..8]);
1670 out.extend_from_slice(&((12 + 8 + json.len() + rest.len()) as u32).to_le_bytes());
1671 out.extend_from_slice(&(json.len() as u32).to_le_bytes());
1672 out.extend_from_slice(b"JSON");
1673 out.extend_from_slice(&json);
1674 out.extend_from_slice(rest);
1675 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1676 let path = std::env::temp_dir().join(format!(
1677 "ppd-anim-{}-{}.glb",
1678 std::process::id(),
1679 N.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1680 ));
1681 std::fs::write(&path, out).unwrap();
1682 path
1683 }
1684
1685 #[test]
1686 fn unskinned_mesh_nodes_follow_their_joint_ancestor() {
1687 let base = load_gltf_file(
1688 &std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1689 .join("tests/fixtures/bodies/test-body-male.glb"),
1690 )
1691 .unwrap();
1692 let path = glb_with(|d| {
1693 let nodes = d["nodes"].as_array_mut().unwrap();
1694 nodes.push(serde_json::json!({"mesh": 0, "translation": [0.0, 0.5, 0.2]}));
1695 let kids = nodes[4]["children"].as_array().cloned().unwrap_or_default();
1696 let mut kids = kids;
1697 kids.push(serde_json::json!(20));
1698 nodes[4]["children"] = serde_json::Value::Array(kids);
1699 });
1700 let both = load_gltf_file(&path).unwrap();
1701 let n = base.positions.len();
1702 assert_eq!(both.positions.len(), 2 * n);
1703 let j = both.joint_nodes.iter().position(|x| *x == 4).unwrap() as u16;
1704 assert!(both.joints[n..].iter().all(|k| k[0] == j));
1705 assert!(both.weights[n..].iter().all(|w| w[0] == 1.0));
1706 // At rest, skinning reproduces the node's world placement.
1707 let locals: Vec<Mat4> = both
1708 .nodes
1709 .iter()
1710 .map(|x| trs(x.translation, x.rotation, x.scale))
1711 .collect();
1712 let world = world_matrices(&both.nodes, &locals);
1713 let skin = mul(world[4], both.inverse_binds[j as usize]);
1714 for (k, p) in both.positions[n..].iter().enumerate().take(20) {
1715 let got = transform_point3(&skin, *p);
1716 // Source vertex = base mesh vertex in skin space; world = node20 * v.
1717 let v = base.positions[k];
1718 let want = transform_point3(&world[20], v);
1719 for a in 0..3 {
1720 assert!((got[a] - want[a]).abs() < 1e-3, "{got:?} vs {want:?}");
1721 }
1722 }
1723 let _ = std::fs::remove_file(path);
1724 }
1725
1726 #[test]
1727 fn texture_transform_matches_extension_example() {
1728 // Spec example: lower-left quadrant, rotated 90° clockwise.
1729 let m = uv_transform([0., 1.], std::f32::consts::FRAC_PI_2, [0.5, 0.5]);
1730 let close =
1731 |a: [f32; 2], b: [f32; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6;
1732 assert!(close(apply_uv(&m, [0., 0.]), [0., 1.]));
1733 assert!(close(apply_uv(&m, [1., 0.]), [0., 0.5]));
1734 assert!(close(apply_uv(&m, [0., 1.]), [0.5, 1.]));
1735 // Spec example: flip T.
1736 let m = uv_transform([0., 1.], 0., [1., -1.]);
1737 assert!(close(apply_uv(&m, [0.25, 0.25]), [0.25, 0.75]));
1738 }
1739
1740 #[test]
1741 fn gltf_texture_transform_is_baked_into_uvs() {
1742 let base = load_gltf_file(
1743 &std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1744 .join("tests/fixtures/bodies/test-body-male.glb"),
1745 )
1746 .unwrap();
1747 let path = glb_with(|d| {
1748 d["images"] = serde_json::json!([{"uri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC"}]);
1749 d["textures"] = serde_json::json!([{"source": 0}]);
1750 for m in d["materials"].as_array_mut().unwrap() {
1751 let pbr = &mut m["pbrMetallicRoughness"];
1752 if pbr.get("baseColorTexture").is_none() {
1753 pbr["baseColorTexture"] = serde_json::json!({"index": 0});
1754 }
1755 pbr["baseColorTexture"]["extensions"] = serde_json::json!(
1756 {"KHR_texture_transform": {"offset": [0.5, 0.25], "scale": [2.0, 3.0]}});
1757 }
1758 });
1759 let moved = load_gltf_file(&path);
1760 let _ = std::fs::remove_file(&path);
1761 let moved = match moved {
1762 Ok(m) => m,
1763 Err(e) => panic!("{e}"),
1764 };
1765 for (a, b) in base.uvs.iter().zip(&moved.uvs).take(50) {
1766 assert!((a[0] * 2.0 + 0.5 - b[0]).abs() < 1e-5);
1767 assert!((a[1] * 3.0 + 0.25 - b[1]).abs() < 1e-5);
1768 }
1769 }
1770
1771 #[test]
1772 fn affine_inverse_round_trips() {
1773 let m = trs(
1774 [1., 2., 3.],
1775 [0.0, 0.38268343, 0.0, 0.9238795],
1776 [2., 1., 0.5],
1777 );
1778 let i = affine_inverse(&m).unwrap();
1779 let id = mul(m, i);
1780 for (k, v) in id.iter().enumerate() {
1781 let want = if k % 5 == 0 { 1.0 } else { 0.0 };
1782 assert!((v - want).abs() < 1e-5);
1783 }
1784 }
1785
1786 #[test]
1787 fn gltf_step_is_kept_bad_cubic_rejected_unnamed_clips_get_index_names() {
1788 let base = load_gltf_file(
1789 &std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1790 .join("tests/fixtures/bodies/test-body-male.glb"),
1791 )
1792 .unwrap();
1793 assert!(base.clips.iter().all(|c| c
1794 .channels
1795 .iter()
1796 .all(|ch| ch.interpolation == Interpolation::Linear)));
1797 let step = glb_with(|d| {
1798 for a in d["animations"].as_array_mut().unwrap() {
1799 for s in a["samplers"].as_array_mut().unwrap() {
1800 s["interpolation"] = "STEP".into();
1801 }
1802 a.as_object_mut().unwrap().remove("name");
1803 }
1804 });
1805 let loaded = load_gltf_file(&step).unwrap();
1806 assert!(loaded.clips.iter().all(|c| c
1807 .channels
1808 .iter()
1809 .all(|ch| ch.interpolation == Interpolation::Step)));
1810 assert_eq!(loaded.clips[0].name, "animatie 0");
1811 assert_ne!(loaded.clips[0].name, loaded.clips[1].name);
1812 // Between keys a STEP channel holds the left key.
1813 let ch = loaded.clips[0]
1814 .channels
1815 .iter()
1816 .find(|c| c.times.len() > 2)
1817 .unwrap();
1818 let mid = (ch.times[0] + ch.times[1]) / 2.;
1819 assert_eq!(ch.sample(mid), ch.sample(ch.times[0]));
1820 // CUBICSPLINE without tangent triples is rejected explicitly.
1821 let cubic = glb_with(|d| {
1822 d["animations"][0]["samplers"][0]["interpolation"] = "CUBICSPLINE".into();
1823 });
1824 let err = load_gltf_file(&cubic).err().unwrap();
1825 assert!(err.contains("ongeldig kanaal"), "{err}");
1826 let _ = std::fs::remove_file(step);
1827 let _ = std::fs::remove_file(cubic);
1828 }
1829
1830 fn male() -> PreviewCharacter {
1831 load_gltf_file(
1832 &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),
1833 )
1834 .unwrap()
1835 }
1836
1837 fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {
1838 let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
1839 dot(d, d).sqrt()
1840 }
1841
1842 #[test]
1843 fn mirrored_static_gltf_node_flips_winding_and_keeps_outward_normals() {
1844 let dir = std::env::temp_dir().join(format!(
1845 "paperdoll-static-mirror-{}-{:?}",
1846 std::process::id(),
1847 std::thread::current().id()
1848 ));
1849 std::fs::create_dir_all(&dir).unwrap();
1850 // One triangle facing -Z (counter-clockwise seen from -Z), normal -Z.
1851 let pos: [[f32; 3]; 3] = [[0., 0., 0.], [0., 1., 0.], [1., 0., 0.]];
1852 let mut bin = Vec::new();
1853 for p in pos {
1854 for v in p {
1855 bin.extend(v.to_le_bytes());
1856 }
1857 }
1858 for _ in 0..3 {
1859 for v in [0f32, 0., -1.] {
1860 bin.extend(v.to_le_bytes());
1861 }
1862 }
1863 std::fs::write(dir.join("tri.bin"), &bin).unwrap();
1864 let gltf = serde_json::json!({
1865 "asset": {"version": "2.0"},
1866 "scene": 0,
1867 "scenes": [{"nodes": [0]}],
1868 "nodes": [{"mesh": 0, "scale": [-2.0, 1.0, 1.0], "translation": [0.0, 0.5, 0.0]}],
1869 "meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}}]}],
1870 "buffers": [{"uri": "tri.bin", "byteLength": bin.len()}],
1871 "bufferViews": [
1872 {"buffer": 0, "byteOffset": 0, "byteLength": 36},
1873 {"buffer": 0, "byteOffset": 36, "byteLength": 36}
1874 ],
1875 "accessors": [
1876 {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3",
1877 "min": [0.0, 0.0, 0.0], "max": [1.0, 1.0, 0.0]},
1878 {"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC3"}
1879 ]
1880 });
1881 std::fs::write(dir.join("tri.gltf"), gltf.to_string()).unwrap();
1882 let body = load_body_file(&dir.join("tri.gltf")).unwrap();
1883 std::fs::remove_dir_all(&dir).ok();
1884 assert!(body.is_static());
1885 assert_eq!(body.positions[2], [-2., 0.5, 0.]);
1886 assert_eq!(body.indices, vec![0, 2, 1], "mirror must flip winding");
1887 let [a, b, c] = [0, 2, 1].map(|i| body.positions[i]);
1888 let n = cross(
1889 [b[0] - a[0], b[1] - a[1], b[2] - a[2]],
1890 [c[0] - a[0], c[1] - a[1], c[2] - a[2]],
1891 );
1892 assert!(n[2] < 0., "winding still faces -Z: {n:?}");
1893 for normal in &body.normals {
1894 assert!((normal[2] + 1.).abs() < 1e-6, "{normal:?}");
1895 }
1896 assert!(normal_matrix(&trs([0.; 3], [0., 0., 0., 1.], [0., 1., 1.])).is_none());
1897 }
1898
1899 #[test]
1900 fn walk_and_attack_move_the_matching_bones() {
1901 let character = male();
1902 assert!(character.clips.iter().any(|clip| clip.name == "walk"));
1903 assert!(character.clips.iter().any(|clip| clip.name == "attack"));
1904 assert_eq!(character.joint_names.len(), 19);
1905 assert!(character.indices.len() >= 3);
1906 assert!(character.positions.len() >= 19 * 8);
1907 let leg_a = joint_world_translation(&character, "walk", 0.0, "LeftLowerLeg").unwrap();
1908 let leg_b = joint_world_translation(&character, "walk", 0.25, "LeftLowerLeg").unwrap();
1909 assert!(
1910 distance(leg_a, leg_b) > 0.05,
1911 "walk should move the lower leg, delta {}",
1912 distance(leg_a, leg_b)
1913 );
1914 let arm_a = joint_world_translation(&character, "attack", 0.0, "RightHand").unwrap();
1915 let arm_b = joint_world_translation(&character, "attack", 0.35, "RightHand").unwrap();
1916 assert!(
1917 distance(arm_a, arm_b) > 0.05,
1918 "attack should move the right hand, delta {}",
1919 distance(arm_a, arm_b)
1920 );
1921 let matrices = sample_skin_matrices(&character, "walk", 0.2);
1922 assert_eq!(matrices.len(), 19);
1923 assert!(matrices.iter().flatten().all(|value| value.is_finite()));
1924 }
1925
1926 #[test]
1927 fn female_fixture_has_the_same_clip_names() {
1928 let character = load_gltf_file(
1929 &Path::new(env!("CARGO_MANIFEST_DIR"))
1930 .join("tests/fixtures/bodies/test-body-female.glb"),
1931 )
1932 .unwrap();
1933 assert!(character.clips.iter().any(|clip| clip.name == "walk"));
1934 assert!(character.clips.iter().any(|clip| clip.name == "attack"));
1935 let a = joint_world_translation(&character, "walk", 0.0, "RightLowerLeg").unwrap();
1936 let b = joint_world_translation(&character, "walk", 0.5, "RightLowerLeg").unwrap();
1937 assert!(distance(a, b) > 0.05);
1938 }
1939
1940 #[test]
1941 fn yaw_zero_is_in_front_and_the_twelve_yaws_differ() {
1942 let front = eye_offset(0.0, 0.0, 4.0);
1943 assert!(
1944 front[2] > 3.5,
1945 "yaw 0 camera belongs on +Z (glTF front), got {front:?}"
1946 );
1947 assert!(front[0].abs() < 0.05);
1948 let mut seen = Vec::new();
1949 for yaw in paperdoll_yaws_deg() {
1950 let eye = eye_offset(yaw, 0.0, 4.0);
1951 assert!(seen
1952 .iter()
1953 .all(|previous: &[f32; 3]| distance(*previous, eye) > 0.5));
1954 seen.push(eye);
1955 }
1956 assert_eq!(seen.len(), 12);
1957 let low = eye_offset(0.0, -30.0, 4.0);
1958 let high = eye_offset(0.0, 30.0, 4.0);
1959 assert!(low[1] < 0.0 && high[1] > 0.0);
1960 assert_eq!(PAPERDOLL_PITCHES_DEG.len(), 5);
1961 assert_eq!(PRODUCTION_VIEWS as usize, PRODUCTION_PITCHES.len() * 12);
1962 }
1963
1964 #[test]
1965 fn controls_snap_wrap_and_keep_a_clip_change() {
1966 let mut controls = PreviewControls::default();
1967 controls.drag(-100.0, 40.0);
1968 assert!(controls.yaw_deg > 40.0);
1969 assert!(controls.pitch_deg > 10.0);
1970 controls.zoom(400.0);
1971 assert!(controls.distance < 3.4);
1972 controls.snap_yaw_index(-1);
1973 assert!((controls.yaw_deg - 330.0).abs() < 0.01);
1974 controls.snap_pitch_row(99);
1975 assert!((controls.pitch_deg - 60.0).abs() < 0.01);
1976 controls.snap_pitch_row(-4);
1977 assert!((controls.pitch_deg + 60.0).abs() < 0.01);
1978 controls.snap_pitch_row(3);
1979 assert!((controls.pitch_deg - 30.0).abs() < 0.01);
1980 controls.time = 0.4;
1981 controls.select_clip("attack");
1982 assert_eq!(controls.clip_name, "attack");
1983 assert_eq!(controls.time, 0.0);
1984 controls.time = 1.2;
1985 controls.advance(0.0, 1.0);
1986 controls.playing = true;
1987 controls.advance(0.3, 1.0);
1988 assert!((controls.time - 0.5).abs() < 0.02 || (wrap_time(1.5, 1.0) - 0.5).abs() < 0.02);
1989 }
1990}