BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / preview.rs

preview.rs

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