BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / preview.rs

preview.rs

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