BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / preview.rs

preview.rs

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