BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / preview.rs

preview.rs

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