BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / preview.rs

preview.rs

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