BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / preview.rs

preview.rs

preview.rs 45.2 KiB Download Raw
1//! CPU skeleton sampling and orbit camera for the live glTF preview.
2//!
3//! This is not the paperdoll export. The export camera is orthographic and
4//! still out of scope. The interactive camera can snap to the same five
5//! pitches and twelve yaws. The character forward axis of the test bodies is -Z,
6//! so yaw 0 places the camera on -Z, looking at the body.
7
8use std::path::Path;
9
10pub const PAPERDOLL_PITCHES_DEG: [f32; 5] = [-60.0, -30.0, 0.0, 30.0, 60.0];
11/// Production pitches in integer degrees, atlas column order (t955u).
12pub const PRODUCTION_PITCHES: [i32; 5] = [-60, -30, 0, 30, 60];
13/// Production views per sample: five pitches × twelve yaws.
14pub const PRODUCTION_VIEWS: u32 = 60;
15pub const PAPERDOLL_YAW_COUNT: usize = 12;
16pub const MAX_JOINTS: usize = 128;
17
18pub type Mat4 = [f32; 16];
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum PreviewSex {
22 Male,
23 Female,
24}
25
26#[derive(Debug, Clone)]
27pub struct PreviewControls {
28 pub sex: PreviewSex,
29 pub clip_name: String,
30 pub time: f32,
31 pub playing: bool,
32 pub yaw_deg: f32,
33 pub pitch_deg: f32,
34 pub distance: f32,
35}
36
37impl Default for PreviewControls {
38 fn default() -> Self {
39 Self {
40 sex: PreviewSex::Male,
41 clip_name: "walk".to_string(),
42 time: 0.0,
43 playing: true,
44 yaw_deg: 0.0,
45 pitch_deg: 0.0,
46 distance: 3.4,
47 }
48 }
49}
50
51impl PreviewControls {
52 pub fn drag(&mut self, dx_px: f32, dy_px: f32) {
53 self.yaw_deg = wrap_deg(self.yaw_deg - dx_px * 0.45);
54 self.pitch_deg = (self.pitch_deg + dy_px * 0.35).clamp(-80.0, 80.0);
55 }
56
57 pub fn zoom(&mut self, scroll_y: f32) {
58 let factor = (-scroll_y * 0.0015).exp();
59 self.distance = (self.distance * factor).clamp(0.8, 12.0);
60 }
61
62 pub fn snap_yaw_index(&mut self, index: i32) {
63 let wrapped = index.rem_euclid(PAPERDOLL_YAW_COUNT as i32) as f32;
64 self.yaw_deg = wrapped * 30.0;
65 }
66
67 pub fn snap_pitch_row(&mut self, row: i32) {
68 let row = row.clamp(0, PAPERDOLL_PITCHES_DEG.len() as i32 - 1) as usize;
69 self.pitch_deg = PAPERDOLL_PITCHES_DEG[row];
70 }
71
72 pub fn advance(&mut self, dt: f32, duration: f32) {
73 if self.playing && duration > 0.0 {
74 self.time = wrap_time(self.time + dt, duration);
75 }
76 }
77
78 pub fn select_clip(&mut self, name: &str) {
79 if self.clip_name != name {
80 self.clip_name = name.to_string();
81 self.time = 0.0;
82 }
83 }
84}
85
86#[derive(Debug, Clone)]
87pub struct PreviewCharacter {
88 pub nodes: Vec<SceneNode>,
89 pub joint_nodes: Vec<usize>,
90 pub joint_names: Vec<String>,
91 pub inverse_binds: Vec<Mat4>,
92 pub positions: Vec<[f32; 3]>,
93 pub normals: Vec<[f32; 3]>,
94 /// A placement-only fallback must never masquerade as a production normal map.
95 pub source_normals_complete: bool,
96 pub joints: Vec<[u16; 4]>,
97 pub weights: Vec<[f32; 4]>,
98 pub indices: Vec<u32>,
99 pub base_color: [f32; 4],
100 pub colors: Vec<[f32; 4]>,
101 pub uvs: Vec<[f32; 2]>,
102 pub surfaces: Vec<BodySurface>,
103 pub clips: Vec<PreviewClip>,
104 /// Skinned (skeleton + clips) or static geometry without a skeleton.
105 pub rig: BodyRig,
106}
107
108/// Whether a body has a real skin. A static body keeps an empty skeleton and
109/// no clips; it is drawn in its stored (bind/static) pose and the production
110/// batch renders the single `clip000-bind` sample. Nothing invents bones.
111#[derive(Debug, Clone, PartialEq, Eq, Default)]
112pub enum BodyRig {
113 #[default]
114 Skinned,
115 Static {
116 /// Source format label, e.g. `OBJ`, `glTF`, `FBX`.
117 format: String,
118 /// Human-readable (Dutch) explanation shown in the editor.
119 reason: String,
120 },
121}
122
123impl PreviewCharacter {
124 /// True when the body has no skin: no bones, no clips, static pose only.
125 pub fn is_static(&self) -> bool {
126 matches!(self.rig, BodyRig::Static { .. })
127 }
128 /// Editor explanation for a static body; `None` for a skinned body.
129 pub fn static_reason(&self) -> Option<&str> {
130 match &self.rig {
131 BodyRig::Static { reason, .. } => Some(reason),
132 BodyRig::Skinned => None,
133 }
134 }
135}
136
137#[derive(Debug, Clone)]
138pub struct BodySurface {
139 pub first: usize,
140 pub count: usize,
141 pub color: [f32; 4],
142 pub albedo: Option<std::sync::Arc<crate::equipment::Texture>>,
143 pub cutoff: f32,
144 pub flat_normals: bool,
145}
146
147#[derive(Debug, Clone)]
148pub struct SceneNode {
149 pub name: String,
150 pub parent: Option<usize>,
151 pub translation: [f32; 3],
152 pub rotation: [f32; 4],
153 pub scale: [f32; 3],
154}
155
156#[derive(Debug, Clone)]
157pub struct PreviewClip {
158 pub name: String,
159 pub duration: f32,
160 pub channels: Vec<AnimChannel>,
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum ChannelProperty {
165 Translation,
166 Rotation,
167 Scale,
168}
169
170#[derive(Debug, Clone)]
171pub struct AnimChannel {
172 pub node: usize,
173 pub property: ChannelProperty,
174 pub times: Vec<f32>,
175 pub values: Vec<f32>,
176}
177
178/// Whether a body model path is drawable by the live viewport and production batch.
179pub fn is_drawable_body(path: &Path) -> bool {
180 matches!(
181 path.extension()
182 .and_then(|e| e.to_str())
183 .map(|e| e.to_ascii_lowercase())
184 .as_deref(),
185 Some("gltf" | "glb" | "fbx" | "obj")
186 )
187}
188
189/// Load a body for the live viewport and the production batch.
190/// glTF/GLB use the glTF reader; FBX goes through the normalized
191/// `fbx_scene` loader (axis/unit conversion is applied there exactly once).
192/// Bodies without a skin (OBJ+MTL, glTF without `skins`, FBX without a skin
193/// deformer) load as [`BodyRig::Static`]: geometry in its stored pose, no
194/// skeleton and no clips.
195pub fn load_body_file(path: &Path) -> Result<PreviewCharacter, String> {
196 match path
197 .extension()
198 .and_then(|e| e.to_str())
199 .map(|e| e.to_ascii_lowercase())
200 .as_deref()
201 {
202 Some("gltf" | "glb") => load_gltf_file(path),
203 Some("fbx") => match crate::fbx_scene::load_fbx_drawable(path) {
204 Ok(scene) => Ok(scene.character),
205 Err(error) if error == crate::fbx_scene::NO_SKINNED_MESH => load_static_rigid(
206 path,
207 "FBX",
208 "FBX zonder skin: statische rustpose, geen skeleton of animaties",
209 ),
210 Err(error) => Err(error),
211 },
212 Some("obj") => load_static_rigid(
213 path,
214 "OBJ",
215 "OBJ heeft geen skeleton of animaties: statische pose",
216 ),
217 _ => Err(format!(
218 "{} is geen tekenbaar lichaam; gebruik .glb, .gltf, .fbx of .obj",
219 path.display()
220 )),
221 }
222}
223
224/// Static OBJ/FBX body through the same rigid reader as equipment
225/// (`equipment::import_obj`/`import_fbx`): OBJ is used as stored, FBX gets
226/// the ufbx axis/unit/geometry-transform normalization once, there. The only
227/// conversion here is the UV origin (bottom-left in OBJ/FBX, top-left in
228/// `PreviewCharacter`), done exactly once.
229fn load_static_rigid(path: &Path, format: &str, reason: &str) -> Result<PreviewCharacter, String> {
230 let mut preset = crate::equipment::Preset::new("static-body".into());
231 let part = match format {
232 "OBJ" => crate::equipment::import_obj_limited(
233 &mut preset,
234 path,
235 crate::equipment::MAX_STATIC_BODY_VERTICES,
236 ),
237 _ => crate::equipment::import_fbx_limited(
238 &mut preset,
239 path,
240 crate::equipment::MAX_STATIC_BODY_VERTICES,
241 ),
242 }
243 .map_err(|error| format!("statisch {format}-lichaam: {error}"))?;
244 let mut cache = crate::equipment::TextureCache::default();
245 let mut acc = StaticAcc::default();
246 for mesh in &preset.attachments[part].meshes {
247 let albedo = mesh
248 .albedo
249 .map(|index| cache.get(&preset, index))
250 .transpose()?;
251 let uvs: Vec<[f32; 2]> = mesh.uvs.iter().map(|[u, v]| [*u, 1. - *v]).collect();
252 acc.push(
253 &mesh.positions,
254 &mesh.normals,
255 &uvs,
256 None,
257 &mesh.indices,
258 mesh.color,
259 albedo,
260 0.001,
261 false,
262 )?;
263 }
264 acc.finish(format, reason.to_string())
265}
266
267/// Accumulates static-body primitives into one `PreviewCharacter` mesh.
268#[derive(Default)]
269struct StaticAcc {
270 positions: Vec<[f32; 3]>,
271 normals: Vec<[f32; 3]>,
272 uvs: Vec<[f32; 2]>,
273 colors: Vec<[f32; 4]>,
274 indices: Vec<u32>,
275 surfaces: Vec<BodySurface>,
276 normals_complete: bool,
277 any: bool,
278}
279
280impl StaticAcc {
281 #[allow(clippy::too_many_arguments)]
282 fn push(
283 &mut self,
284 positions: &[[f32; 3]],
285 normals: &[[f32; 3]],
286 uvs: &[[f32; 2]],
287 colors: Option<&[[f32; 4]]>,
288 indices: &[u32],
289 color: [f32; 4],
290 albedo: Option<std::sync::Arc<crate::equipment::Texture>>,
291 cutoff: f32,
292 flat_normals: bool,
293 ) -> Result<(), String> {
294 if positions.is_empty() || indices.len() < 3 {
295 return Ok(());
296 }
297 if !indices.len().is_multiple_of(3)
298 || indices.iter().any(|i| *i as usize >= positions.len())
299 {
300 return Err("statisch lichaam: ongeldige driehoeksindices".into());
301 }
302 if positions
303 .iter()
304 .chain(normals.iter())
305 .any(|v| v.iter().any(|x| !x.is_finite()))
306 {
307 return Err("statisch lichaam: niet-eindige posities of normalen".into());
308 }
309 let total = self.positions.len() + positions.len();
310 if total > crate::equipment::MAX_STATIC_BODY_VERTICES {
311 return Err("statisch lichaam heeft te veel vertices".into());
312 }
313 if !self.any {
314 self.normals_complete = true;
315 self.any = true;
316 }
317 self.normals_complete &= !flat_normals && normals.len() == positions.len();
318 let base = self.positions.len() as u32;
319 let first = self.indices.len();
320 self.indices.extend(indices.iter().map(|i| i + base));
321 self.positions.extend_from_slice(positions);
322 for i in 0..positions.len() {
323 self.normals
324 .push(normals.get(i).copied().unwrap_or([0., 1., 0.]));
325 self.uvs.push(uvs.get(i).copied().unwrap_or([0., 0.]));
326 self.colors
327 .push(colors.and_then(|c| c.get(i).copied()).unwrap_or([1.; 4]));
328 }
329 self.surfaces.push(BodySurface {
330 first,
331 count: self.indices.len() - first,
332 color,
333 albedo,
334 cutoff,
335 flat_normals,
336 });
337 Ok(())
338 }
339
340 fn finish(self, format: &str, reason: String) -> Result<PreviewCharacter, String> {
341 if self.positions.is_empty() || self.indices.len() < 3 {
342 return Err(format!(
343 "statisch {format}-lichaam heeft geen tekenbare driehoeken"
344 ));
345 }
346 let count = self.positions.len();
347 Ok(PreviewCharacter {
348 nodes: Vec::new(),
349 joint_nodes: Vec::new(),
350 joint_names: Vec::new(),
351 inverse_binds: Vec::new(),
352 positions: self.positions,
353 normals: self.normals,
354 source_normals_complete: self.normals_complete,
355 // Weight 1 on slot 0 with no bones: every skinning path resolves
356 // to identity (empty bone list), i.e. the stored pose.
357 joints: vec![[0; 4]; count],
358 weights: vec![[1., 0., 0., 0.]; count],
359 indices: self.indices,
360 base_color: self
361 .surfaces
362 .first()
363 .map(|s| s.color)
364 .unwrap_or([0.7, 0.7, 0.75, 1.0]),
365 colors: self.colors,
366 uvs: self.uvs,
367 surfaces: self.surfaces,
368 clips: Vec::new(),
369 rig: BodyRig::Static {
370 format: format.to_string(),
371 reason,
372 },
373 })
374 }
375}
376
377pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
378 let (document, buffers, images) = gltf::import(path).map_err(|err| err.to_string())?;
379 let mut nodes = Vec::new();
380 let mut parents = vec![None; document.nodes().len()];
381 for node in document.nodes() {
382 for child in node.children() {
383 parents[child.index()] = Some(node.index());
384 }
385 }
386 for node in document.nodes() {
387 let (translation, rotation, scale) = node.transform().decomposed();
388 nodes.push(SceneNode {
389 name: node.name().unwrap_or("").to_string(),
390 parent: parents[node.index()],
391 translation,
392 rotation,
393 scale,
394 });
395 }
396 let Some(skin) = document.skins().next() else {
397 return load_static_gltf(&document, &buffers, &images, &nodes);
398 };
399 if skin.joints().len() > MAX_JOINTS {
400 return Err(format!(
401 "het skeleton heeft {} joints; de preview stopt bij {MAX_JOINTS}",
402 skin.joints().len()
403 ));
404 }
405 let joint_nodes: Vec<usize> = skin.joints().map(|joint| joint.index()).collect();
406 let joint_names: Vec<String> = joint_nodes
407 .iter()
408 .map(|index| nodes[*index].name.clone())
409 .collect();
410 let reader = skin.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
411 let inverse_binds = reader
412 .read_inverse_bind_matrices()
413 .map(|iter| iter.map(columns_to_mat).collect())
414 .unwrap_or_else(|| vec![identity(); joint_nodes.len()]);
415 if inverse_binds.len() != joint_nodes.len() {
416 return Err("inverse-bindmatrices en joints verschillen".to_string());
417 }
418 let mut positions = Vec::new();
419 let mut normals = Vec::new();
420 let mut source_normals_complete = true;
421 let mut joints = Vec::new();
422 let mut weights = Vec::new();
423 let mut indices = Vec::new();
424 let mut base_color = [0.7, 0.7, 0.75, 1.0];
425 let mut colors = Vec::new();
426 let mut uvs = Vec::new();
427 let mut surfaces = Vec::new();
428 if let Some(mesh) = document.meshes().next() {
429 for primitive in mesh.primitives() {
430 let prim = read_gltf_primitive(&primitive, &buffers, &images)?;
431 source_normals_complete &= prim.normals.is_some();
432 let count = prim.positions.len();
433 let first = indices.len();
434 let base = positions.len() as u32;
435 indices.extend(prim.indices.iter().map(|index| index + base));
436 positions.extend(prim.positions);
437 normals.extend(prim.normals.unwrap_or_else(|| vec![[0.0, 1.0, 0.0]; count]));
438 joints.extend(prim.joints);
439 weights.extend(prim.weights);
440 colors.extend(prim.colors);
441 uvs.extend(prim.uvs);
442 base_color = prim.color;
443 surfaces.push(BodySurface {
444 flat_normals: prim.flat_normals,
445 first,
446 count: indices.len() - first,
447 color: prim.color,
448 albedo: prim.albedo,
449 cutoff: prim.cutoff,
450 });
451 }
452 }
453 if positions.is_empty() || indices.len() < 3 {
454 return Err("het glTF-bestand heeft geen tekenbare driehoeken".to_string());
455 }
456 let mut clips = Vec::new();
457 for animation in document.animations() {
458 let mut channels = Vec::new();
459 let mut duration = 0.0_f32;
460 for channel in animation.channels() {
461 let reader = channel.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
462 let times: Vec<f32> = reader
463 .read_inputs()
464 .map(|iter| iter.collect())
465 .unwrap_or_default();
466 if let Some(last) = times.last() {
467 duration = duration.max(*last);
468 }
469 let Some(outputs) = reader.read_outputs() else {
470 continue;
471 };
472 let (property, values) = match outputs {
473 gltf::animation::util::ReadOutputs::Translations(values) => {
474 (ChannelProperty::Translation, values.flatten().collect())
475 }
476 gltf::animation::util::ReadOutputs::Scales(values) => {
477 (ChannelProperty::Scale, values.flatten().collect())
478 }
479 gltf::animation::util::ReadOutputs::Rotations(values) => (
480 ChannelProperty::Rotation,
481 values.into_f32().flatten().collect(),
482 ),
483 gltf::animation::util::ReadOutputs::MorphTargetWeights(_) => continue,
484 };
485 channels.push(AnimChannel {
486 node: channel.target().node().index(),
487 property,
488 times,
489 values,
490 });
491 }
492 clips.push(PreviewClip {
493 name: animation.name().unwrap_or("naamloos").to_string(),
494 duration,
495 channels,
496 });
497 }
498 Ok(PreviewCharacter {
499 nodes,
500 joint_nodes,
501 joint_names,
502 inverse_binds,
503 positions,
504 normals,
505 source_normals_complete,
506 joints,
507 weights,
508 indices,
509 base_color,
510 colors,
511 uvs,
512 surfaces,
513 clips,
514 rig: BodyRig::Skinned,
515 })
516}
517
518/// One glTF triangle primitive read in its mesh-local space.
519struct GltfPrimitive {
520 positions: Vec<[f32; 3]>,
521 normals: Option<Vec<[f32; 3]>>,
522 joints: Vec<[u16; 4]>,
523 weights: Vec<[f32; 4]>,
524 colors: Vec<[f32; 4]>,
525 uvs: Vec<[f32; 2]>,
526 indices: Vec<u32>,
527 color: [f32; 4],
528 albedo: Option<std::sync::Arc<crate::equipment::Texture>>,
529 cutoff: f32,
530 flat_normals: bool,
531}
532
533fn read_gltf_primitive(
534 primitive: &gltf::Primitive<'_>,
535 buffers: &[gltf::buffer::Data],
536 images: &[gltf::image::Data],
537) -> Result<GltfPrimitive, String> {
538 if primitive.mode() != gltf::mesh::Mode::Triangles {
539 return Err("live/productie ondersteunt alleen driehoekprimitieven".into());
540 }
541 let reader = primitive.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
542 let positions: Vec<[f32; 3]> = reader
543 .read_positions()
544 .map(|iter| iter.collect())
545 .unwrap_or_default();
546 let normals: Option<Vec<[f32; 3]>> = reader.read_normals().map(|iter| iter.collect());
547 let joints: Vec<[u16; 4]> = reader
548 .read_joints(0)
549 .map(|iter| iter.into_u16().collect())
550 .unwrap_or_else(|| vec![[0, 0, 0, 0]; positions.len()]);
551 let weights: Vec<[f32; 4]> = reader
552 .read_weights(0)
553 .map(|iter| iter.into_f32().collect())
554 .unwrap_or_else(|| vec![[1.0, 0.0, 0.0, 0.0]; positions.len()]);
555 let material = primitive.material();
556 let pbr = material.pbr_metallic_roughness();
557 let texture = pbr.base_color_texture();
558 let uvset = texture.as_ref().map(|t| t.tex_coord()).unwrap_or(0);
559 let colors = reader
560 .read_colors(0)
561 .map(|v| v.into_rgba_f32().collect::<Vec<_>>())
562 .unwrap_or_else(|| vec![[1.; 4]; positions.len()]);
563 let uvs = reader
564 .read_tex_coords(uvset)
565 .map(|v| v.into_f32().collect::<Vec<_>>())
566 .unwrap_or_else(|| vec![[0.; 2]; positions.len()]);
567 let indices: Vec<u32> = match reader.read_indices() {
568 Some(read_indices) => read_indices.into_u32().collect(),
569 None => (0..positions.len() as u32).collect(),
570 };
571 let albedo = texture
572 .map(|t| {
573 let image = images
574 .get(t.texture().source().index())
575 .ok_or("ontbrekende bodytexture")?;
576 use gltf::image::Format;
577 let channels = match image.format {
578 Format::R8 => 1,
579 Format::R8G8 => 2,
580 Format::R8G8B8 => 3,
581 Format::R8G8B8A8 => 4,
582 _ => return Err("bodytexture moet 8-bit zijn".to_string()),
583 };
584 if image.width > 2048 || image.height > 2048 {
585 return Err("bodytexture groter dan 2048×2048".into());
586 }
587 let mut rgba = Vec::new();
588 for p in image.pixels.chunks_exact(channels) {
589 rgba.extend_from_slice(&match channels {
590 1 => [p[0], p[0], p[0], 255],
591 2 => [p[0], p[0], p[0], p[1]],
592 3 => [p[0], p[1], p[2], 255],
593 _ => [p[0], p[1], p[2], p[3]],
594 });
595 }
596 use std::hash::{Hash, Hasher};
597 let mut hash = std::collections::hash_map::DefaultHasher::new();
598 rgba.hash(&mut hash);
599 Ok(std::sync::Arc::new(crate::equipment::Texture {
600 content_hash: hash.finish(),
601 width: image.width,
602 height: image.height,
603 rgba,
604 }))
605 })
606 .transpose()?;
607 Ok(GltfPrimitive {
608 flat_normals: normals.is_none(),
609 positions,
610 normals,
611 joints,
612 weights,
613 colors,
614 uvs,
615 indices,
616 color: pbr.base_color_factor(),
617 albedo,
618 cutoff: if material.alpha_mode() == gltf::material::AlphaMode::Mask {
619 material.alpha_cutoff().unwrap_or(0.5)
620 } else {
621 0.001
622 },
623 })
624}
625
626/// glTF without a skin: every mesh instance of the default scene, with its
627/// node's rest world transform baked into positions (and the inverse-
628/// transpose into normals) exactly once; mirrored transforms flip winding.
629/// Animations are not applied (no skeleton is invented).
630fn load_static_gltf(
631 document: &gltf::Document,
632 buffers: &[gltf::buffer::Data],
633 images: &[gltf::image::Data],
634 nodes: &[SceneNode],
635) -> Result<PreviewCharacter, String> {
636 let mut roots: Vec<usize> = match document
637 .default_scene()
638 .or_else(|| document.scenes().next())
639 {
640 Some(scene) => scene.nodes().map(|n| n.index()).collect(),
641 None => (0..nodes.len())
642 .filter(|i| nodes[*i].parent.is_none())
643 .collect(),
644 };
645 let children: Vec<Vec<usize>> = document
646 .nodes()
647 .map(|n| n.children().map(|c| c.index()).collect())
648 .collect();
649 let mut acc = StaticAcc::default();
650 let mut stack: Vec<(usize, Mat4, usize)> =
651 roots.drain(..).rev().map(|r| (r, identity(), 0)).collect();
652 let mut visited = 0usize;
653 while let Some((index, parent, depth)) = stack.pop() {
654 visited += 1;
655 if depth > nodes.len() || visited > nodes.len() * 4 + 16 {
656 return Err("glTF-nodehiërarchie bevat een cyclus".into());
657 }
658 let node = nodes.get(index).ok_or("ongeldige glTF-node")?;
659 let world = mul(parent, trs(node.translation, node.rotation, node.scale));
660 if !world.iter().all(|x| x.is_finite()) {
661 return Err("glTF-node heeft een niet-eindige transformatie".into());
662 }
663 if let Some(mesh) = document.nodes().nth(index).and_then(|n| n.mesh()) {
664 let normal_matrix =
665 normal_matrix(&world).ok_or("glTF-node heeft een singuliere transformatie")?;
666 let mirrored = det3(&world) < 0.0;
667 for primitive in mesh.primitives() {
668 let prim = read_gltf_primitive(&primitive, buffers, images)?;
669 let positions: Vec<[f32; 3]> = prim
670 .positions
671 .iter()
672 .map(|p| transform_point3(&world, *p))
673 .collect();
674 let normals: Vec<[f32; 3]> = match &prim.normals {
675 Some(normals) => normals
676 .iter()
677 .map(|n| normalize3(mat3_mul(&normal_matrix, *n)))
678 .collect(),
679 None => vec![[0., 1., 0.]; positions.len()],
680 };
681 let mut indices = prim.indices.clone();
682 if !indices.len().is_multiple_of(3) {
683 return Err("glTF-primitive heeft een onvolledige driehoek".into());
684 }
685 if mirrored {
686 for tri in indices.chunks_exact_mut(3) {
687 tri.swap(1, 2);
688 }
689 }
690 acc.push(
691 &positions,
692 &normals,
693 &prim.uvs,
694 Some(&prim.colors),
695 &indices,
696 prim.color,
697 prim.albedo,
698 prim.cutoff,
699 prim.flat_normals,
700 )?;
701 }
702 }
703 for child in children.get(index).into_iter().flatten().rev() {
704 stack.push((*child, world, depth + 1));
705 }
706 }
707 let animations = document.animations().count();
708 let reason = if animations == 0 {
709 "glTF zonder skin: statische pose, geen skeleton of animaties".to_string()
710 } else {
711 format!(
712 "glTF zonder skin: statische pose; {animations} animatie(s) niet toegepast (geen skeleton)"
713 )
714 };
715 acc.finish("glTF", reason)
716}
717
718fn transform_point3(m: &Mat4, p: [f32; 3]) -> [f32; 3] {
719 [
720 m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
721 m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
722 m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14],
723 ]
724}
725
726fn det3(m: &Mat4) -> f32 {
727 m[0] * (m[5] * m[10] - m[9] * m[6]) - m[4] * (m[1] * m[10] - m[9] * m[2])
728 + m[8] * (m[1] * m[6] - m[5] * m[2])
729}
730
731/// Inverse-transpose of the upper 3×3 (row-major result for `mat3_mul`).
732fn normal_matrix(m: &Mat4) -> Option<[[f32; 3]; 3]> {
733 let det = det3(m);
734 if !det.is_finite() || det.abs() < 1e-12 {
735 return None;
736 }
737 // a[r][c] = element at row r, column c of the column-major matrix.
738 let a = |r: usize, c: usize| m[c * 4 + r];
739 let cof = |r: usize, c: usize| {
740 let rows: Vec<usize> = (0..3).filter(|x| *x != r).collect();
741 let cols: Vec<usize> = (0..3).filter(|x| *x != c).collect();
742 let minor =
743 a(rows[0], cols[0]) * a(rows[1], cols[1]) - a(rows[0], cols[1]) * a(rows[1], cols[0]);
744 if (r + c).is_multiple_of(2) {
745 minor
746 } else {
747 -minor
748 }
749 };
750 // inverse-transpose = cofactor matrix / det.
751 let mut out = [[0.; 3]; 3];
752 for (r, row) in out.iter_mut().enumerate() {
753 for (c, value) in row.iter_mut().enumerate() {
754 *value = cof(r, c) / det;
755 }
756 }
757 Some(out)
758}
759
760fn mat3_mul(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
761 [
762 m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
763 m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
764 m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
765 ]
766}
767
768pub fn sample_skin_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {
769 let locals = sample_local_matrices(character, clip_name, time);
770 let worlds = world_matrices(&character.nodes, &locals);
771 character
772 .joint_nodes
773 .iter()
774 .enumerate()
775 .map(|(joint, node)| mul(worlds[*node], character.inverse_binds[joint]))
776 .collect()
777}
778
779/// Stable hierarchy path includes node indices to disambiguate duplicate names.
780pub fn joint_paths(character: &PreviewCharacter) -> Vec<String> {
781 character
782 .joint_nodes
783 .iter()
784 .map(|&node| {
785 let mut names = Vec::new();
786 let mut cursor = Some(node);
787 while let Some(index) = cursor {
788 let item = &character.nodes[index];
789 names.push(format!("{}[{}]", item.name, index));
790 cursor = item.parent;
791 if names.len() > character.nodes.len() {
792 break;
793 }
794 }
795 names.reverse();
796 names.join("/")
797 })
798 .collect()
799}
800
801/// Rest-world joint transform, never selected by a possibly empty clip name.
802/// Bounded parent walk rejects malformed cycles/indices instead of recursing.
803pub fn rest_joint_world(character: &PreviewCharacter, joint: usize) -> Option<Mat4> {
804 let mut node = Some(*character.joint_nodes.get(joint)?);
805 let mut chain = Vec::new();
806 while let Some(i) = node {
807 if chain.len() >= character.nodes.len() {
808 return None;
809 }
810 let n = character.nodes.get(i)?;
811 chain.push(i);
812 node = n.parent;
813 }
814 let mut world = identity();
815 for i in chain.into_iter().rev() {
816 let n = &character.nodes[i];
817 world = mul(world, trs(n.translation, n.rotation, n.scale));
818 }
819 world.iter().all(|n| n.is_finite()).then_some(world)
820}
821
822pub fn animated_joint_world(
823 character: &PreviewCharacter,
824 clip: &str,
825 time: f32,
826 joint: usize,
827) -> Option<Mat4> {
828 let locals = sample_local_matrices(character, clip, time);
829 let worlds = world_matrices(&character.nodes, &locals);
830 character
831 .joint_nodes
832 .get(joint)
833 .and_then(|node| worlds.get(*node))
834 .copied()
835}
836
837pub fn joint_world_translation(
838 character: &PreviewCharacter,
839 clip_name: &str,
840 time: f32,
841 joint_name: &str,
842) -> Option<[f32; 3]> {
843 let joint = character
844 .joint_names
845 .iter()
846 .position(|name| name == joint_name)?;
847 let node = character.joint_nodes[joint];
848 let locals = sample_local_matrices(character, clip_name, time);
849 let worlds = world_matrices(&character.nodes, &locals);
850 Some([worlds[node][12], worlds[node][13], worlds[node][14]])
851}
852
853pub fn clip_duration(character: &PreviewCharacter, clip_name: &str) -> f32 {
854 character
855 .clips
856 .iter()
857 .find(|clip| {
858 clip.name == clip_name || clip.name.to_lowercase().contains(&clip_name.to_lowercase())
859 })
860 .map(|clip| clip.duration)
861 .unwrap_or(0.0)
862}
863
864pub fn eye_offset(yaw_deg: f32, pitch_deg: f32, distance: f32) -> [f32; 3] {
865 let yaw = yaw_deg.to_radians();
866 let pitch = pitch_deg.to_radians();
867 [
868 -distance * pitch.cos() * yaw.sin(),
869 distance * pitch.sin(),
870 -distance * pitch.cos() * yaw.cos(),
871 ]
872}
873
874pub fn paperdoll_yaws_deg() -> [f32; PAPERDOLL_YAW_COUNT] {
875 let mut yaws = [0.0; PAPERDOLL_YAW_COUNT];
876 for (index, yaw) in yaws.iter_mut().enumerate() {
877 *yaw = index as f32 * 30.0;
878 }
879 yaws
880}
881
882fn sample_local_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {
883 let mut translation: Vec<[f32; 3]> = character
884 .nodes
885 .iter()
886 .map(|node| node.translation)
887 .collect();
888 let mut rotation: Vec<[f32; 4]> = character.nodes.iter().map(|node| node.rotation).collect();
889 let mut scale: Vec<[f32; 3]> = character.nodes.iter().map(|node| node.scale).collect();
890 if let Some(clip) = character.clips.iter().find(|clip| {
891 clip.name == clip_name || clip.name.to_lowercase().contains(&clip_name.to_lowercase())
892 }) {
893 let time = wrap_time(time, clip.duration);
894 for channel in &clip.channels {
895 if channel.node >= character.nodes.len() || channel.times.is_empty() {
896 continue;
897 }
898 let (left, right, blend) = key_blend(&channel.times, time);
899 match channel.property {
900 ChannelProperty::Translation => {
901 translation[channel.node] = lerp3(
902 key3(&channel.values, left),
903 key3(&channel.values, right),
904 blend,
905 );
906 }
907 ChannelProperty::Scale => {
908 scale[channel.node] = lerp3(
909 key3(&channel.values, left),
910 key3(&channel.values, right),
911 blend,
912 );
913 }
914 ChannelProperty::Rotation => {
915 rotation[channel.node] = slerp(
916 key4(&channel.values, left),
917 key4(&channel.values, right),
918 blend,
919 );
920 }
921 }
922 }
923 }
924 translation
925 .into_iter()
926 .zip(rotation)
927 .zip(scale)
928 .map(|((translation, rotation), scale)| trs(translation, rotation, scale))
929 .collect()
930}
931
932fn world_matrices(nodes: &[SceneNode], locals: &[Mat4]) -> Vec<Mat4> {
933 fn world(
934 index: usize,
935 nodes: &[SceneNode],
936 locals: &[Mat4],
937 worlds: &mut [Option<Mat4>],
938 depth: usize,
939 ) -> Mat4 {
940 if let Some(matrix) = worlds[index] {
941 return matrix;
942 }
943 if depth > nodes.len() {
944 return locals[index];
945 }
946 let matrix = match nodes[index].parent {
947 Some(parent) => mul(
948 world(parent, nodes, locals, worlds, depth + 1),
949 locals[index],
950 ),
951 None => locals[index],
952 };
953 worlds[index] = Some(matrix);
954 matrix
955 }
956 let mut cached = vec![None; nodes.len()];
957 (0..nodes.len())
958 .map(|index| world(index, nodes, locals, &mut cached, 0))
959 .collect()
960}
961
962fn key_blend(times: &[f32], time: f32) -> (usize, usize, f32) {
963 if times.len() == 1 || time <= times[0] {
964 return (0, 0, 0.0);
965 }
966 if time >= times[times.len() - 1] {
967 let last = times.len() - 1;
968 return (last, last, 0.0);
969 }
970 let mut index = 0;
971 while index + 1 < times.len() && times[index + 1] < time {
972 index += 1;
973 }
974 let span = times[index + 1] - times[index];
975 let blend = if span.abs() < 1e-6 {
976 0.0
977 } else {
978 (time - times[index]) / span
979 };
980 (index, index + 1, blend)
981}
982
983fn key3(values: &[f32], index: usize) -> [f32; 3] {
984 let start = index * 3;
985 [
986 values.get(start).copied().unwrap_or(0.0),
987 values.get(start + 1).copied().unwrap_or(0.0),
988 values.get(start + 2).copied().unwrap_or(0.0),
989 ]
990}
991
992fn key4(values: &[f32], index: usize) -> [f32; 4] {
993 let start = index * 4;
994 [
995 values.get(start).copied().unwrap_or(0.0),
996 values.get(start + 1).copied().unwrap_or(0.0),
997 values.get(start + 2).copied().unwrap_or(0.0),
998 values.get(start + 3).copied().unwrap_or(1.0),
999 ]
1000}
1001
1002fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
1003 [
1004 a[0] + (b[0] - a[0]) * t,
1005 a[1] + (b[1] - a[1]) * t,
1006 a[2] + (b[2] - a[2]) * t,
1007 ]
1008}
1009
1010fn slerp(a: [f32; 4], mut b: [f32; 4], t: f32) -> [f32; 4] {
1011 let mut dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
1012 if dot < 0.0 {
1013 dot = -dot;
1014 b = [-b[0], -b[1], -b[2], -b[3]];
1015 }
1016 if dot > 0.9995 {
1017 return normalize4([
1018 a[0] + (b[0] - a[0]) * t,
1019 a[1] + (b[1] - a[1]) * t,
1020 a[2] + (b[2] - a[2]) * t,
1021 a[3] + (b[3] - a[3]) * t,
1022 ]);
1023 }
1024 let theta = dot.clamp(-1.0, 1.0).acos();
1025 let sin_theta = theta.sin();
1026 let w1 = ((1.0 - t) * theta).sin() / sin_theta;
1027 let w2 = (t * theta).sin() / sin_theta;
1028 [
1029 a[0] * w1 + b[0] * w2,
1030 a[1] * w1 + b[1] * w2,
1031 a[2] * w1 + b[2] * w2,
1032 a[3] * w1 + b[3] * w2,
1033 ]
1034}
1035
1036fn normalize4(value: [f32; 4]) -> [f32; 4] {
1037 let length =
1038 (value[0] * value[0] + value[1] * value[1] + value[2] * value[2] + value[3] * value[3])
1039 .sqrt();
1040 if length < 1e-8 {
1041 [0.0, 0.0, 0.0, 1.0]
1042 } else {
1043 [
1044 value[0] / length,
1045 value[1] / length,
1046 value[2] / length,
1047 value[3] / length,
1048 ]
1049 }
1050}
1051
1052fn trs(translation: [f32; 3], rotation: [f32; 4], scale: [f32; 3]) -> Mat4 {
1053 let mut matrix = quat_to_mat(normalize4(rotation));
1054 matrix[0] *= scale[0];
1055 matrix[1] *= scale[0];
1056 matrix[2] *= scale[0];
1057 matrix[4] *= scale[1];
1058 matrix[5] *= scale[1];
1059 matrix[6] *= scale[1];
1060 matrix[8] *= scale[2];
1061 matrix[9] *= scale[2];
1062 matrix[10] *= scale[2];
1063 matrix[12] = translation[0];
1064 matrix[13] = translation[1];
1065 matrix[14] = translation[2];
1066 matrix
1067}
1068
1069fn quat_to_mat(q: [f32; 4]) -> Mat4 {
1070 let (x, y, z, w) = (q[0], q[1], q[2], q[3]);
1071 let xx = x * x;
1072 let yy = y * y;
1073 let zz = z * z;
1074 let xy = x * y;
1075 let xz = x * z;
1076 let yz = y * z;
1077 let wx = w * x;
1078 let wy = w * y;
1079 let wz = w * z;
1080 [
1081 1.0 - 2.0 * (yy + zz),
1082 2.0 * (xy + wz),
1083 2.0 * (xz - wy),
1084 0.0,
1085 2.0 * (xy - wz),
1086 1.0 - 2.0 * (xx + zz),
1087 2.0 * (yz + wx),
1088 0.0,
1089 2.0 * (xz + wy),
1090 2.0 * (yz - wx),
1091 1.0 - 2.0 * (xx + yy),
1092 0.0,
1093 0.0,
1094 0.0,
1095 0.0,
1096 1.0,
1097 ]
1098}
1099
1100pub fn identity() -> Mat4 {
1101 [
1102 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,
1103 ]
1104}
1105
1106pub fn mul(left: Mat4, right: Mat4) -> Mat4 {
1107 let mut out = [0.0; 16];
1108 for column in 0..4 {
1109 for row in 0..4 {
1110 out[column * 4 + row] = left[row] * right[column * 4]
1111 + left[4 + row] * right[column * 4 + 1]
1112 + left[8 + row] * right[column * 4 + 2]
1113 + left[12 + row] * right[column * 4 + 3];
1114 }
1115 }
1116 out
1117}
1118
1119fn columns_to_mat(columns: [[f32; 4]; 4]) -> Mat4 {
1120 let mut matrix = [0.0; 16];
1121 for column in 0..4 {
1122 for row in 0..4 {
1123 matrix[column * 4 + row] = columns[column][row];
1124 }
1125 }
1126 matrix
1127}
1128
1129pub fn wrap_time(time: f32, duration: f32) -> f32 {
1130 if duration <= 1e-5 {
1131 return 0.0;
1132 }
1133 let mut wrapped = time % duration;
1134 if wrapped < 0.0 {
1135 wrapped += duration;
1136 }
1137 wrapped
1138}
1139
1140fn wrap_deg(mut degrees: f32) -> f32 {
1141 degrees %= 360.0;
1142 if degrees < 0.0 {
1143 degrees += 360.0;
1144 }
1145 degrees
1146}
1147
1148pub fn view_matrix(eye: [f32; 3], target: [f32; 3]) -> Mat4 {
1149 let forward = normalize3([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]);
1150 let up = [0.0, 1.0, 0.0];
1151 let mut side = normalize3(cross(forward, up));
1152 if side[0].abs() + side[1].abs() + side[2].abs() < 1e-4 {
1153 side = [1.0, 0.0, 0.0];
1154 }
1155 let up = cross(side, forward);
1156 [
1157 side[0],
1158 up[0],
1159 -forward[0],
1160 0.0,
1161 side[1],
1162 up[1],
1163 -forward[1],
1164 0.0,
1165 side[2],
1166 up[2],
1167 -forward[2],
1168 0.0,
1169 -dot(side, eye),
1170 -dot(up, eye),
1171 dot(forward, eye),
1172 1.0,
1173 ]
1174}
1175
1176pub fn perspective(fov_y_deg: f32, aspect: f32, near: f32, far: f32) -> Mat4 {
1177 let f = 1.0 / (fov_y_deg.to_radians() * 0.5).tan();
1178 let aspect = if aspect.abs() < 1e-4 { 1.0 } else { aspect };
1179 let nf = 1.0 / (near - far);
1180 [
1181 f / aspect,
1182 0.0,
1183 0.0,
1184 0.0,
1185 0.0,
1186 f,
1187 0.0,
1188 0.0,
1189 0.0,
1190 0.0,
1191 (far + near) * nf,
1192 -1.0,
1193 0.0,
1194 0.0,
1195 2.0 * far * near * nf,
1196 0.0,
1197 ]
1198}
1199
1200fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
1201 [
1202 a[1] * b[2] - a[2] * b[1],
1203 a[2] * b[0] - a[0] * b[2],
1204 a[0] * b[1] - a[1] * b[0],
1205 ]
1206}
1207
1208fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
1209 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
1210}
1211
1212fn normalize3(value: [f32; 3]) -> [f32; 3] {
1213 let length = dot(value, value).sqrt();
1214 if length < 1e-8 {
1215 [0.0, 0.0, 1.0]
1216 } else {
1217 [value[0] / length, value[1] / length, value[2] / length]
1218 }
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223 use super::*;
1224
1225 fn male() -> PreviewCharacter {
1226 load_gltf_file(
1227 &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),
1228 )
1229 .unwrap()
1230 }
1231
1232 fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {
1233 let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
1234 dot(d, d).sqrt()
1235 }
1236
1237 #[test]
1238 fn mirrored_static_gltf_node_flips_winding_and_keeps_outward_normals() {
1239 let dir = std::env::temp_dir().join(format!(
1240 "paperdoll-static-mirror-{}-{:?}",
1241 std::process::id(),
1242 std::thread::current().id()
1243 ));
1244 std::fs::create_dir_all(&dir).unwrap();
1245 // One triangle facing -Z (counter-clockwise seen from -Z), normal -Z.
1246 let pos: [[f32; 3]; 3] = [[0., 0., 0.], [0., 1., 0.], [1., 0., 0.]];
1247 let mut bin = Vec::new();
1248 for p in pos {
1249 for v in p {
1250 bin.extend(v.to_le_bytes());
1251 }
1252 }
1253 for _ in 0..3 {
1254 for v in [0f32, 0., -1.] {
1255 bin.extend(v.to_le_bytes());
1256 }
1257 }
1258 std::fs::write(dir.join("tri.bin"), &bin).unwrap();
1259 let gltf = serde_json::json!({
1260 "asset": {"version": "2.0"},
1261 "scene": 0,
1262 "scenes": [{"nodes": [0]}],
1263 "nodes": [{"mesh": 0, "scale": [-2.0, 1.0, 1.0], "translation": [0.0, 0.5, 0.0]}],
1264 "meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}}]}],
1265 "buffers": [{"uri": "tri.bin", "byteLength": bin.len()}],
1266 "bufferViews": [
1267 {"buffer": 0, "byteOffset": 0, "byteLength": 36},
1268 {"buffer": 0, "byteOffset": 36, "byteLength": 36}
1269 ],
1270 "accessors": [
1271 {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3",
1272 "min": [0.0, 0.0, 0.0], "max": [1.0, 1.0, 0.0]},
1273 {"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC3"}
1274 ]
1275 });
1276 std::fs::write(dir.join("tri.gltf"), gltf.to_string()).unwrap();
1277 let body = load_body_file(&dir.join("tri.gltf")).unwrap();
1278 std::fs::remove_dir_all(&dir).ok();
1279 assert!(body.is_static());
1280 assert_eq!(body.positions[2], [-2., 0.5, 0.]);
1281 assert_eq!(body.indices, vec![0, 2, 1], "mirror must flip winding");
1282 let [a, b, c] = [0, 2, 1].map(|i| body.positions[i]);
1283 let n = cross(
1284 [b[0] - a[0], b[1] - a[1], b[2] - a[2]],
1285 [c[0] - a[0], c[1] - a[1], c[2] - a[2]],
1286 );
1287 assert!(n[2] < 0., "winding still faces -Z: {n:?}");
1288 for normal in &body.normals {
1289 assert!((normal[2] + 1.).abs() < 1e-6, "{normal:?}");
1290 }
1291 assert!(normal_matrix(&trs([0.; 3], [0., 0., 0., 1.], [0., 1., 1.])).is_none());
1292 }
1293
1294 #[test]
1295 fn walk_and_attack_move_the_matching_bones() {
1296 let character = male();
1297 assert!(character.clips.iter().any(|clip| clip.name == "walk"));
1298 assert!(character.clips.iter().any(|clip| clip.name == "attack"));
1299 assert_eq!(character.joint_names.len(), 19);
1300 assert!(character.indices.len() >= 3);
1301 assert!(character.positions.len() >= 19 * 8);
1302 let leg_a = joint_world_translation(&character, "walk", 0.0, "LeftLowerLeg").unwrap();
1303 let leg_b = joint_world_translation(&character, "walk", 0.25, "LeftLowerLeg").unwrap();
1304 assert!(
1305 distance(leg_a, leg_b) > 0.05,
1306 "walk should move the lower leg, delta {}",
1307 distance(leg_a, leg_b)
1308 );
1309 let arm_a = joint_world_translation(&character, "attack", 0.0, "RightHand").unwrap();
1310 let arm_b = joint_world_translation(&character, "attack", 0.35, "RightHand").unwrap();
1311 assert!(
1312 distance(arm_a, arm_b) > 0.05,
1313 "attack should move the right hand, delta {}",
1314 distance(arm_a, arm_b)
1315 );
1316 let matrices = sample_skin_matrices(&character, "walk", 0.2);
1317 assert_eq!(matrices.len(), 19);
1318 assert!(matrices.iter().flatten().all(|value| value.is_finite()));
1319 }
1320
1321 #[test]
1322 fn female_fixture_has_the_same_clip_names() {
1323 let character = load_gltf_file(
1324 &Path::new(env!("CARGO_MANIFEST_DIR"))
1325 .join("tests/fixtures/bodies/test-body-female.glb"),
1326 )
1327 .unwrap();
1328 assert!(character.clips.iter().any(|clip| clip.name == "walk"));
1329 assert!(character.clips.iter().any(|clip| clip.name == "attack"));
1330 let a = joint_world_translation(&character, "walk", 0.0, "RightLowerLeg").unwrap();
1331 let b = joint_world_translation(&character, "walk", 0.5, "RightLowerLeg").unwrap();
1332 assert!(distance(a, b) > 0.05);
1333 }
1334
1335 #[test]
1336 fn yaw_zero_is_in_front_and_the_twelve_yaws_differ() {
1337 let front = eye_offset(0.0, 0.0, 4.0);
1338 assert!(front[2] < -3.5, "yaw 0 camera belongs on -Z, got {front:?}");
1339 assert!(front[0].abs() < 0.05);
1340 let mut seen = Vec::new();
1341 for yaw in paperdoll_yaws_deg() {
1342 let eye = eye_offset(yaw, 0.0, 4.0);
1343 assert!(seen
1344 .iter()
1345 .all(|previous: &[f32; 3]| distance(*previous, eye) > 0.5));
1346 seen.push(eye);
1347 }
1348 assert_eq!(seen.len(), 12);
1349 let low = eye_offset(0.0, -30.0, 4.0);
1350 let high = eye_offset(0.0, 30.0, 4.0);
1351 assert!(low[1] < 0.0 && high[1] > 0.0);
1352 assert_eq!(PAPERDOLL_PITCHES_DEG.len(), 5);
1353 assert_eq!(PRODUCTION_VIEWS as usize, PRODUCTION_PITCHES.len() * 12);
1354 }
1355
1356 #[test]
1357 fn controls_snap_wrap_and_keep_a_clip_change() {
1358 let mut controls = PreviewControls::default();
1359 controls.drag(-100.0, 40.0);
1360 assert!(controls.yaw_deg > 40.0);
1361 assert!(controls.pitch_deg > 10.0);
1362 controls.zoom(400.0);
1363 assert!(controls.distance < 3.4);
1364 controls.snap_yaw_index(-1);
1365 assert!((controls.yaw_deg - 330.0).abs() < 0.01);
1366 controls.snap_pitch_row(99);
1367 assert!((controls.pitch_deg - 60.0).abs() < 0.01);
1368 controls.snap_pitch_row(-4);
1369 assert!((controls.pitch_deg + 60.0).abs() < 0.01);
1370 controls.snap_pitch_row(3);
1371 assert!((controls.pitch_deg - 30.0).abs() < 0.01);
1372 controls.time = 0.4;
1373 controls.select_clip("attack");
1374 assert_eq!(controls.clip_name, "attack");
1375 assert_eq!(controls.time, 0.0);
1376 controls.time = 1.2;
1377 controls.advance(0.0, 1.0);
1378 controls.playing = true;
1379 controls.advance(0.3, 1.0);
1380 assert!((controls.time - 0.5).abs() < 0.02 || (wrap_time(1.5, 1.0) - 0.5).abs() < 0.02);
1381 }
1382}