BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / preview.rs

preview.rs

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