BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / preview.rs

preview.rs

preview.rs 62.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 uvset = texture.as_ref().map(|t| t.tex_coord()).unwrap_or(0);
787 let colors = reader
788 .read_colors(0)
789 .map(|v| v.into_rgba_f32().collect::<Vec<_>>())
790 .unwrap_or_else(|| vec![[1.; 4]; positions.len()]);
791 let uvs = reader
792 .read_tex_coords(uvset)
793 .map(|v| v.into_f32().collect::<Vec<_>>())
794 .unwrap_or_else(|| vec![[0.; 2]; positions.len()]);
795 let indices: Vec<u32> = match reader.read_indices() {
796 Some(read_indices) => read_indices.into_u32().collect(),
797 None => (0..positions.len() as u32).collect(),
798 };
799 let albedo = texture
800 .map(|t| {
801 let image = images
802 .get(t.texture().source().index())
803 .ok_or("ontbrekende bodytexture")?;
804 use gltf::image::Format;
805 let channels = match image.format {
806 Format::R8 => 1,
807 Format::R8G8 => 2,
808 Format::R8G8B8 => 3,
809 Format::R8G8B8A8 => 4,
810 _ => return Err("bodytexture moet 8-bit zijn".to_string()),
811 };
812 if image.width > 2048 || image.height > 2048 {
813 return Err("bodytexture groter dan 2048×2048".into());
814 }
815 let mut rgba = Vec::new();
816 for p in image.pixels.chunks_exact(channels) {
817 rgba.extend_from_slice(&match channels {
818 1 => [p[0], p[0], p[0], 255],
819 2 => [p[0], p[0], p[0], p[1]],
820 3 => [p[0], p[1], p[2], 255],
821 _ => [p[0], p[1], p[2], p[3]],
822 });
823 }
824 use std::hash::{Hash, Hasher};
825 let mut hash = std::collections::hash_map::DefaultHasher::new();
826 rgba.hash(&mut hash);
827 Ok(std::sync::Arc::new(crate::equipment::Texture {
828 content_hash: hash.finish(),
829 width: image.width,
830 height: image.height,
831 rgba,
832 }))
833 })
834 .transpose()?;
835 Ok(GltfPrimitive {
836 flat_normals: normals.is_none(),
837 positions,
838 normals,
839 joints,
840 weights,
841 colors,
842 uvs,
843 indices,
844 color: pbr.base_color_factor(),
845 albedo,
846 cutoff: if material.alpha_mode() == gltf::material::AlphaMode::Mask {
847 material.alpha_cutoff().unwrap_or(0.5)
848 } else {
849 0.001
850 },
851 })
852}
853
854/// glTF without a skin: every mesh instance of the default scene, with its
855/// node's rest world transform baked into positions (and the inverse-
856/// transpose into normals) exactly once; mirrored transforms flip winding.
857/// Animations are not applied (no skeleton is invented).
858fn load_static_gltf(
859 document: &gltf::Document,
860 buffers: &[gltf::buffer::Data],
861 images: &[gltf::image::Data],
862 nodes: &[SceneNode],
863) -> Result<PreviewCharacter, String> {
864 let mut roots: Vec<usize> = match document
865 .default_scene()
866 .or_else(|| document.scenes().next())
867 {
868 Some(scene) => scene.nodes().map(|n| n.index()).collect(),
869 None => (0..nodes.len())
870 .filter(|i| nodes[*i].parent.is_none())
871 .collect(),
872 };
873 let children: Vec<Vec<usize>> = document
874 .nodes()
875 .map(|n| n.children().map(|c| c.index()).collect())
876 .collect();
877 let mut acc = StaticAcc::default();
878 let mut stack: Vec<(usize, Mat4, usize)> =
879 roots.drain(..).rev().map(|r| (r, identity(), 0)).collect();
880 let mut visited = 0usize;
881 while let Some((index, parent, depth)) = stack.pop() {
882 visited += 1;
883 if depth > nodes.len() || visited > nodes.len() * 4 + 16 {
884 return Err("glTF-nodehiërarchie bevat een cyclus".into());
885 }
886 let node = nodes.get(index).ok_or("ongeldige glTF-node")?;
887 let world = mul(parent, trs(node.translation, node.rotation, node.scale));
888 if !world.iter().all(|x| x.is_finite()) {
889 return Err("glTF-node heeft een niet-eindige transformatie".into());
890 }
891 if let Some(mesh) = document.nodes().nth(index).and_then(|n| n.mesh()) {
892 let normal_matrix =
893 normal_matrix(&world).ok_or("glTF-node heeft een singuliere transformatie")?;
894 let mirrored = det3(&world) < 0.0;
895 for primitive in mesh.primitives() {
896 let prim = read_gltf_primitive(&primitive, buffers, images)?;
897 let positions: Vec<[f32; 3]> = prim
898 .positions
899 .iter()
900 .map(|p| transform_point3(&world, *p))
901 .collect();
902 let normals: Vec<[f32; 3]> = match &prim.normals {
903 Some(normals) => normals
904 .iter()
905 .map(|n| normalize3(mat3_mul(&normal_matrix, *n)))
906 .collect(),
907 None => vec![[0., 1., 0.]; positions.len()],
908 };
909 let mut indices = prim.indices.clone();
910 if !indices.len().is_multiple_of(3) {
911 return Err("glTF-primitive heeft een onvolledige driehoek".into());
912 }
913 if mirrored {
914 for tri in indices.chunks_exact_mut(3) {
915 tri.swap(1, 2);
916 }
917 }
918 acc.push(
919 &positions,
920 &normals,
921 &prim.uvs,
922 Some(&prim.colors),
923 &indices,
924 prim.color,
925 prim.albedo,
926 prim.cutoff,
927 prim.flat_normals,
928 )?;
929 }
930 }
931 for child in children.get(index).into_iter().flatten().rev() {
932 stack.push((*child, world, depth + 1));
933 }
934 }
935 let animations = document.animations().count();
936 let reason = if animations == 0 {
937 "glTF zonder skin: statische pose, geen skeleton of animaties".to_string()
938 } else {
939 format!(
940 "glTF zonder skin: statische pose; {animations} animatie(s) niet toegepast (geen skeleton)"
941 )
942 };
943 acc.finish("glTF", reason)
944}
945
946/// Inverse of an affine matrix (None when singular).
947fn affine_inverse(m: &Mat4) -> Option<Mat4> {
948 let it = normal_matrix(m)?; // inverse-transpose of the 3×3
949 // inverse 3×3 (row r, col c) = it[c][r]
950 let mut out = identity();
951 for r in 0..3 {
952 for c in 0..3 {
953 out[c * 4 + r] = it[c][r];
954 }
955 }
956 let t = [m[12], m[13], m[14]];
957 for r in 0..3 {
958 out[12 + r] = -(0..3).map(|c| out[c * 4 + r] * t[c]).sum::<f32>();
959 }
960 Some(out)
961}
962
963fn transform_point3(m: &Mat4, p: [f32; 3]) -> [f32; 3] {
964 [
965 m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
966 m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
967 m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14],
968 ]
969}
970
971fn det3(m: &Mat4) -> f32 {
972 m[0] * (m[5] * m[10] - m[9] * m[6]) - m[4] * (m[1] * m[10] - m[9] * m[2])
973 + m[8] * (m[1] * m[6] - m[5] * m[2])
974}
975
976/// Inverse-transpose of the upper 3×3 (row-major result for `mat3_mul`).
977fn normal_matrix(m: &Mat4) -> Option<[[f32; 3]; 3]> {
978 let det = det3(m);
979 if !det.is_finite() || det.abs() < 1e-12 {
980 return None;
981 }
982 // a[r][c] = element at row r, column c of the column-major matrix.
983 let a = |r: usize, c: usize| m[c * 4 + r];
984 let cof = |r: usize, c: usize| {
985 let rows: Vec<usize> = (0..3).filter(|x| *x != r).collect();
986 let cols: Vec<usize> = (0..3).filter(|x| *x != c).collect();
987 let minor =
988 a(rows[0], cols[0]) * a(rows[1], cols[1]) - a(rows[0], cols[1]) * a(rows[1], cols[0]);
989 if (r + c).is_multiple_of(2) {
990 minor
991 } else {
992 -minor
993 }
994 };
995 // inverse-transpose = cofactor matrix / det.
996 let mut out = [[0.; 3]; 3];
997 for (r, row) in out.iter_mut().enumerate() {
998 for (c, value) in row.iter_mut().enumerate() {
999 *value = cof(r, c) / det;
1000 }
1001 }
1002 Some(out)
1003}
1004
1005fn mat3_mul(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
1006 [
1007 m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
1008 m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
1009 m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
1010 ]
1011}
1012
1013pub fn sample_skin_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {
1014 let locals = sample_local_matrices(character, clip_name, time);
1015 let worlds = world_matrices(&character.nodes, &locals);
1016 character
1017 .joint_nodes
1018 .iter()
1019 .enumerate()
1020 .map(|(joint, node)| mul(worlds[*node], character.inverse_binds[joint]))
1021 .collect()
1022}
1023
1024/// Stable hierarchy path includes node indices to disambiguate duplicate names.
1025pub fn joint_paths(character: &PreviewCharacter) -> Vec<String> {
1026 character
1027 .joint_nodes
1028 .iter()
1029 .map(|&node| {
1030 let mut names = Vec::new();
1031 let mut cursor = Some(node);
1032 while let Some(index) = cursor {
1033 let item = &character.nodes[index];
1034 names.push(format!("{}[{}]", item.name, index));
1035 cursor = item.parent;
1036 if names.len() > character.nodes.len() {
1037 break;
1038 }
1039 }
1040 names.reverse();
1041 names.join("/")
1042 })
1043 .collect()
1044}
1045
1046/// Rest-world joint transform, never selected by a possibly empty clip name.
1047/// Bounded parent walk rejects malformed cycles/indices instead of recursing.
1048pub fn rest_joint_world(character: &PreviewCharacter, joint: usize) -> Option<Mat4> {
1049 let mut node = Some(*character.joint_nodes.get(joint)?);
1050 let mut chain = Vec::new();
1051 while let Some(i) = node {
1052 if chain.len() >= character.nodes.len() {
1053 return None;
1054 }
1055 let n = character.nodes.get(i)?;
1056 chain.push(i);
1057 node = n.parent;
1058 }
1059 let mut world = identity();
1060 for i in chain.into_iter().rev() {
1061 let n = &character.nodes[i];
1062 world = mul(world, trs(n.translation, n.rotation, n.scale));
1063 }
1064 world.iter().all(|n| n.is_finite()).then_some(world)
1065}
1066
1067pub fn animated_joint_world(
1068 character: &PreviewCharacter,
1069 clip: &str,
1070 time: f32,
1071 joint: usize,
1072) -> Option<Mat4> {
1073 let locals = sample_local_matrices(character, clip, time);
1074 let worlds = world_matrices(&character.nodes, &locals);
1075 character
1076 .joint_nodes
1077 .get(joint)
1078 .and_then(|node| worlds.get(*node))
1079 .copied()
1080}
1081
1082pub fn joint_world_translation(
1083 character: &PreviewCharacter,
1084 clip_name: &str,
1085 time: f32,
1086 joint_name: &str,
1087) -> Option<[f32; 3]> {
1088 let joint = character
1089 .joint_names
1090 .iter()
1091 .position(|name| name == joint_name)?;
1092 let node = character.joint_nodes[joint];
1093 let locals = sample_local_matrices(character, clip_name, time);
1094 let worlds = world_matrices(&character.nodes, &locals);
1095 Some([worlds[node][12], worlds[node][13], worlds[node][14]])
1096}
1097
1098pub fn clip_duration(character: &PreviewCharacter, clip_name: &str) -> f32 {
1099 character
1100 .clips
1101 .iter()
1102 .find(|clip| {
1103 clip.name == clip_name || clip.name.to_lowercase().contains(&clip_name.to_lowercase())
1104 })
1105 .map(|clip| clip.duration)
1106 .unwrap_or(0.0)
1107}
1108
1109pub fn eye_offset(yaw_deg: f32, pitch_deg: f32, distance: f32) -> [f32; 3] {
1110 let yaw = yaw_deg.to_radians();
1111 let pitch = pitch_deg.to_radians();
1112 [
1113 -distance * pitch.cos() * yaw.sin(),
1114 distance * pitch.sin(),
1115 -distance * pitch.cos() * yaw.cos(),
1116 ]
1117}
1118
1119pub fn paperdoll_yaws_deg() -> [f32; PAPERDOLL_YAW_COUNT] {
1120 let mut yaws = [0.0; PAPERDOLL_YAW_COUNT];
1121 for (index, yaw) in yaws.iter_mut().enumerate() {
1122 *yaw = index as f32 * 30.0;
1123 }
1124 yaws
1125}
1126
1127fn sample_local_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {
1128 let mut translation: Vec<[f32; 3]> = character
1129 .nodes
1130 .iter()
1131 .map(|node| node.translation)
1132 .collect();
1133 let mut rotation: Vec<[f32; 4]> = character.nodes.iter().map(|node| node.rotation).collect();
1134 let mut scale: Vec<[f32; 3]> = character.nodes.iter().map(|node| node.scale).collect();
1135 if let Some(clip) = find_clip(&character.clips, clip_name) {
1136 let time = wrap_time(time, clip.duration);
1137 for channel in &clip.channels {
1138 if channel.node >= character.nodes.len() || channel.validate().is_err() {
1139 continue;
1140 }
1141 let v = channel.sample(time);
1142 match channel.property {
1143 ChannelProperty::Translation => translation[channel.node] = [v[0], v[1], v[2]],
1144 ChannelProperty::Scale => scale[channel.node] = [v[0], v[1], v[2]],
1145 ChannelProperty::Rotation => rotation[channel.node] = [v[0], v[1], v[2], v[3]],
1146 }
1147 }
1148 }
1149 translation
1150 .into_iter()
1151 .zip(rotation)
1152 .zip(scale)
1153 .map(|((translation, rotation), scale)| trs(translation, rotation, scale))
1154 .collect()
1155}
1156
1157fn world_matrices(nodes: &[SceneNode], locals: &[Mat4]) -> Vec<Mat4> {
1158 fn world(
1159 index: usize,
1160 nodes: &[SceneNode],
1161 locals: &[Mat4],
1162 worlds: &mut [Option<Mat4>],
1163 depth: usize,
1164 ) -> Mat4 {
1165 if let Some(matrix) = worlds[index] {
1166 return matrix;
1167 }
1168 if depth > nodes.len() {
1169 return locals[index];
1170 }
1171 let matrix = match nodes[index].parent {
1172 Some(parent) => mul(
1173 world(parent, nodes, locals, worlds, depth + 1),
1174 locals[index],
1175 ),
1176 None => locals[index],
1177 };
1178 worlds[index] = Some(matrix);
1179 matrix
1180 }
1181 let mut cached = vec![None; nodes.len()];
1182 (0..nodes.len())
1183 .map(|index| world(index, nodes, locals, &mut cached, 0))
1184 .collect()
1185}
1186
1187fn key_blend(times: &[f32], time: f32) -> (usize, usize, f32) {
1188 if times.len() == 1 || time <= times[0] {
1189 return (0, 0, 0.0);
1190 }
1191 if time >= times[times.len() - 1] {
1192 let last = times.len() - 1;
1193 return (last, last, 0.0);
1194 }
1195 let mut index = 0;
1196 while index + 1 < times.len() && times[index + 1] < time {
1197 index += 1;
1198 }
1199 let span = times[index + 1] - times[index];
1200 let blend = if span.abs() < 1e-6 {
1201 0.0
1202 } else {
1203 (time - times[index]) / span
1204 };
1205 (index, index + 1, blend)
1206}
1207
1208fn slerp(a: [f32; 4], mut b: [f32; 4], t: f32) -> [f32; 4] {
1209 let mut dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
1210 if dot < 0.0 {
1211 dot = -dot;
1212 b = [-b[0], -b[1], -b[2], -b[3]];
1213 }
1214 if dot > 0.9995 {
1215 return normalize4([
1216 a[0] + (b[0] - a[0]) * t,
1217 a[1] + (b[1] - a[1]) * t,
1218 a[2] + (b[2] - a[2]) * t,
1219 a[3] + (b[3] - a[3]) * t,
1220 ]);
1221 }
1222 let theta = dot.clamp(-1.0, 1.0).acos();
1223 let sin_theta = theta.sin();
1224 let w1 = ((1.0 - t) * theta).sin() / sin_theta;
1225 let w2 = (t * theta).sin() / sin_theta;
1226 [
1227 a[0] * w1 + b[0] * w2,
1228 a[1] * w1 + b[1] * w2,
1229 a[2] * w1 + b[2] * w2,
1230 a[3] * w1 + b[3] * w2,
1231 ]
1232}
1233
1234fn normalize4(value: [f32; 4]) -> [f32; 4] {
1235 let length =
1236 (value[0] * value[0] + value[1] * value[1] + value[2] * value[2] + value[3] * value[3])
1237 .sqrt();
1238 if length < 1e-8 {
1239 [0.0, 0.0, 0.0, 1.0]
1240 } else {
1241 [
1242 value[0] / length,
1243 value[1] / length,
1244 value[2] / length,
1245 value[3] / length,
1246 ]
1247 }
1248}
1249
1250fn trs(translation: [f32; 3], rotation: [f32; 4], scale: [f32; 3]) -> Mat4 {
1251 let mut matrix = quat_to_mat(normalize4(rotation));
1252 matrix[0] *= scale[0];
1253 matrix[1] *= scale[0];
1254 matrix[2] *= scale[0];
1255 matrix[4] *= scale[1];
1256 matrix[5] *= scale[1];
1257 matrix[6] *= scale[1];
1258 matrix[8] *= scale[2];
1259 matrix[9] *= scale[2];
1260 matrix[10] *= scale[2];
1261 matrix[12] = translation[0];
1262 matrix[13] = translation[1];
1263 matrix[14] = translation[2];
1264 matrix
1265}
1266
1267fn quat_to_mat(q: [f32; 4]) -> Mat4 {
1268 let (x, y, z, w) = (q[0], q[1], q[2], q[3]);
1269 let xx = x * x;
1270 let yy = y * y;
1271 let zz = z * z;
1272 let xy = x * y;
1273 let xz = x * z;
1274 let yz = y * z;
1275 let wx = w * x;
1276 let wy = w * y;
1277 let wz = w * z;
1278 [
1279 1.0 - 2.0 * (yy + zz),
1280 2.0 * (xy + wz),
1281 2.0 * (xz - wy),
1282 0.0,
1283 2.0 * (xy - wz),
1284 1.0 - 2.0 * (xx + zz),
1285 2.0 * (yz + wx),
1286 0.0,
1287 2.0 * (xz + wy),
1288 2.0 * (yz - wx),
1289 1.0 - 2.0 * (xx + yy),
1290 0.0,
1291 0.0,
1292 0.0,
1293 0.0,
1294 1.0,
1295 ]
1296}
1297
1298pub fn identity() -> Mat4 {
1299 [
1300 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,
1301 ]
1302}
1303
1304pub fn mul(left: Mat4, right: Mat4) -> Mat4 {
1305 let mut out = [0.0; 16];
1306 for column in 0..4 {
1307 for row in 0..4 {
1308 out[column * 4 + row] = left[row] * right[column * 4]
1309 + left[4 + row] * right[column * 4 + 1]
1310 + left[8 + row] * right[column * 4 + 2]
1311 + left[12 + row] * right[column * 4 + 3];
1312 }
1313 }
1314 out
1315}
1316
1317fn columns_to_mat(columns: [[f32; 4]; 4]) -> Mat4 {
1318 let mut matrix = [0.0; 16];
1319 for column in 0..4 {
1320 for row in 0..4 {
1321 matrix[column * 4 + row] = columns[column][row];
1322 }
1323 }
1324 matrix
1325}
1326
1327pub fn wrap_time(time: f32, duration: f32) -> f32 {
1328 if duration <= 1e-5 {
1329 return 0.0;
1330 }
1331 let mut wrapped = time % duration;
1332 if wrapped < 0.0 {
1333 wrapped += duration;
1334 }
1335 wrapped
1336}
1337
1338fn wrap_deg(mut degrees: f32) -> f32 {
1339 degrees %= 360.0;
1340 if degrees < 0.0 {
1341 degrees += 360.0;
1342 }
1343 degrees
1344}
1345
1346pub fn view_matrix(eye: [f32; 3], target: [f32; 3]) -> Mat4 {
1347 let forward = normalize3([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]);
1348 let up = [0.0, 1.0, 0.0];
1349 let mut side = normalize3(cross(forward, up));
1350 if side[0].abs() + side[1].abs() + side[2].abs() < 1e-4 {
1351 side = [1.0, 0.0, 0.0];
1352 }
1353 let up = cross(side, forward);
1354 [
1355 side[0],
1356 up[0],
1357 -forward[0],
1358 0.0,
1359 side[1],
1360 up[1],
1361 -forward[1],
1362 0.0,
1363 side[2],
1364 up[2],
1365 -forward[2],
1366 0.0,
1367 -dot(side, eye),
1368 -dot(up, eye),
1369 dot(forward, eye),
1370 1.0,
1371 ]
1372}
1373
1374pub fn perspective(fov_y_deg: f32, aspect: f32, near: f32, far: f32) -> Mat4 {
1375 let f = 1.0 / (fov_y_deg.to_radians() * 0.5).tan();
1376 let aspect = if aspect.abs() < 1e-4 { 1.0 } else { aspect };
1377 let nf = 1.0 / (near - far);
1378 [
1379 f / aspect,
1380 0.0,
1381 0.0,
1382 0.0,
1383 0.0,
1384 f,
1385 0.0,
1386 0.0,
1387 0.0,
1388 0.0,
1389 (far + near) * nf,
1390 -1.0,
1391 0.0,
1392 0.0,
1393 2.0 * far * near * nf,
1394 0.0,
1395 ]
1396}
1397
1398fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
1399 [
1400 a[1] * b[2] - a[2] * b[1],
1401 a[2] * b[0] - a[0] * b[2],
1402 a[0] * b[1] - a[1] * b[0],
1403 ]
1404}
1405
1406fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
1407 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
1408}
1409
1410fn normalize3(value: [f32; 3]) -> [f32; 3] {
1411 let length = dot(value, value).sqrt();
1412 if length < 1e-8 {
1413 [0.0, 0.0, 1.0]
1414 } else {
1415 [value[0] / length, value[1] / length, value[2] / length]
1416 }
1417}
1418
1419#[cfg(test)]
1420mod tests {
1421 use super::*;
1422
1423 fn chan(
1424 property: ChannelProperty,
1425 interpolation: Interpolation,
1426 times: &[f32],
1427 values: &[f32],
1428 ) -> AnimChannel {
1429 AnimChannel {
1430 node: 0,
1431 property,
1432 times: times.to_vec(),
1433 values: values.to_vec(),
1434 interpolation,
1435 }
1436 }
1437
1438 #[test]
1439 fn step_linear_and_cubic_interpolation_follow_gltf() {
1440 let lin = chan(
1441 ChannelProperty::Translation,
1442 Interpolation::Linear,
1443 &[0., 1.],
1444 &[0., 0., 0., 2., 4., 6.],
1445 );
1446 assert_eq!(lin.sample(0.25), vec![0.5, 1., 1.5]);
1447 assert_eq!(lin.sample(-1.), vec![0., 0., 0.]);
1448 assert_eq!(lin.sample(5.), vec![2., 4., 6.]);
1449 let step = Interpolation::Step;
1450 let st = chan(
1451 ChannelProperty::Translation,
1452 step,
1453 &[0., 1.],
1454 &[0., 0., 0., 2., 4., 6.],
1455 );
1456 assert_eq!(st.sample(0.99), vec![0., 0., 0.]);
1457 assert_eq!(st.sample(1.), vec![2., 4., 6.]);
1458 // Cubic: (in, value, out) per key; zero tangents give smoothstep.
1459 let cubic = chan(
1460 ChannelProperty::Translation,
1461 Interpolation::CubicSpline,
1462 &[0., 2.],
1463 &[
1464 9., 9., 9., 0., 0., 0., 0., 0., 0., 0., 0., 0., 10., 10., 10., 9., 9., 9.,
1465 ],
1466 );
1467 cubic.validate().unwrap();
1468 let v = cubic.sample(0.5); // t = 0.25 -> h01 = 0.15625
1469 assert!((v[0] - 1.5625).abs() < 1e-5, "{v:?}");
1470 // Tangents are scaled by the key interval: out-tangent 1/s over 2 s.
1471 let sloped = chan(
1472 ChannelProperty::Translation,
1473 Interpolation::CubicSpline,
1474 &[0., 2.],
1475 &[
1476 0., 0., 0., 0., 0., 0., 1., 0., 0., 0., 0., 0., 2., 0., 0., 0., 0., 0.,
1477 ],
1478 );
1479 let v = sloped.sample(1.); // t=.5: h01*2 + h10*2*1 = 1 + 0.25
1480 assert!((v[0] - 1.25).abs() < 1e-5, "{v:?}");
1481 // Cubic rotation stays unit length.
1482 let q = chan(
1483 ChannelProperty::Rotation,
1484 Interpolation::CubicSpline,
1485 &[0., 1.],
1486 &[
1487 0.,
1488 0.,
1489 0.,
1490 0.,
1491 0.,
1492 0.,
1493 0.,
1494 1.,
1495 0.,
1496 0.,
1497 0.,
1498 0.,
1499 0.,
1500 0.,
1501 0.,
1502 0.,
1503 0.,
1504 0.,
1505 std::f32::consts::FRAC_1_SQRT_2,
1506 std::f32::consts::FRAC_1_SQRT_2,
1507 0.,
1508 0.,
1509 0.,
1510 0.,
1511 ],
1512 );
1513 let r = q.sample(0.5);
1514 let len: f32 = r.iter().map(|x| x * x).sum::<f32>().sqrt();
1515 assert!((len - 1.).abs() < 1e-4);
1516 // Validation rejects malformed channels.
1517 assert!(chan(
1518 ChannelProperty::Translation,
1519 Interpolation::CubicSpline,
1520 &[0., 1.],
1521 &[0.; 6]
1522 )
1523 .validate()
1524 .is_err());
1525 assert!(chan(
1526 ChannelProperty::Translation,
1527 Interpolation::Linear,
1528 &[1., 0.],
1529 &[0.; 6]
1530 )
1531 .validate()
1532 .is_err());
1533 assert!(chan(
1534 ChannelProperty::Translation,
1535 Interpolation::Linear,
1536 &[0., f32::NAN],
1537 &[0.; 6]
1538 )
1539 .validate()
1540 .is_err());
1541 assert!(chan(
1542 ChannelProperty::Scale,
1543 Interpolation::Linear,
1544 &[0.],
1545 &[f32::INFINITY, 0., 0.]
1546 )
1547 .validate()
1548 .is_err());
1549 }
1550
1551 #[test]
1552 fn clip_lookup_prefers_exact_name() {
1553 let clip = |name: &str| PreviewClip {
1554 name: name.into(),
1555 duration: 1.,
1556 channels: Vec::new(),
1557 skipped: Vec::new(),
1558 };
1559 let clips = vec![clip("walk_fast"), clip("walk"), clip("Attack")];
1560 assert_eq!(find_clip(&clips, "walk").unwrap().name, "walk");
1561 assert_eq!(find_clip(&clips, "attack").unwrap().name, "Attack");
1562 assert!(find_clip(&clips, "run").is_none());
1563 }
1564
1565 fn glb_with(edit: impl Fn(&mut serde_json::Value)) -> std::path::PathBuf {
1566 let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1567 .join("tests/fixtures/bodies/test-body-male.glb");
1568 let bytes = std::fs::read(src).unwrap();
1569 let json_len = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize;
1570 let mut doc: serde_json::Value = serde_json::from_slice(&bytes[20..20 + json_len]).unwrap();
1571 edit(&mut doc);
1572 let mut json = serde_json::to_vec(&doc).unwrap();
1573 while !json.len().is_multiple_of(4) {
1574 json.push(b' ');
1575 }
1576 let rest = &bytes[20 + json_len..];
1577 let mut out = Vec::new();
1578 out.extend_from_slice(&bytes[0..8]);
1579 out.extend_from_slice(&((12 + 8 + json.len() + rest.len()) as u32).to_le_bytes());
1580 out.extend_from_slice(&(json.len() as u32).to_le_bytes());
1581 out.extend_from_slice(b"JSON");
1582 out.extend_from_slice(&json);
1583 out.extend_from_slice(rest);
1584 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1585 let path = std::env::temp_dir().join(format!(
1586 "ppd-anim-{}-{}.glb",
1587 std::process::id(),
1588 N.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1589 ));
1590 std::fs::write(&path, out).unwrap();
1591 path
1592 }
1593
1594 #[test]
1595 fn unskinned_mesh_nodes_follow_their_joint_ancestor() {
1596 let base = load_gltf_file(
1597 &std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1598 .join("tests/fixtures/bodies/test-body-male.glb"),
1599 )
1600 .unwrap();
1601 let path = glb_with(|d| {
1602 let nodes = d["nodes"].as_array_mut().unwrap();
1603 nodes.push(serde_json::json!({"mesh": 0, "translation": [0.0, 0.5, 0.2]}));
1604 let kids = nodes[4]["children"].as_array().cloned().unwrap_or_default();
1605 let mut kids = kids;
1606 kids.push(serde_json::json!(20));
1607 nodes[4]["children"] = serde_json::Value::Array(kids);
1608 });
1609 let both = load_gltf_file(&path).unwrap();
1610 let n = base.positions.len();
1611 assert_eq!(both.positions.len(), 2 * n);
1612 let j = both.joint_nodes.iter().position(|x| *x == 4).unwrap() as u16;
1613 assert!(both.joints[n..].iter().all(|k| k[0] == j));
1614 assert!(both.weights[n..].iter().all(|w| w[0] == 1.0));
1615 // At rest, skinning reproduces the node's world placement.
1616 let locals: Vec<Mat4> = both
1617 .nodes
1618 .iter()
1619 .map(|x| trs(x.translation, x.rotation, x.scale))
1620 .collect();
1621 let world = world_matrices(&both.nodes, &locals);
1622 let skin = mul(world[4], both.inverse_binds[j as usize]);
1623 for (k, p) in both.positions[n..].iter().enumerate().take(20) {
1624 let got = transform_point3(&skin, *p);
1625 // Source vertex = base mesh vertex in skin space; world = node20 * v.
1626 let v = base.positions[k];
1627 let want = transform_point3(&world[20], v);
1628 for a in 0..3 {
1629 assert!((got[a] - want[a]).abs() < 1e-3, "{got:?} vs {want:?}");
1630 }
1631 }
1632 let _ = std::fs::remove_file(path);
1633 }
1634
1635 #[test]
1636 fn affine_inverse_round_trips() {
1637 let m = trs(
1638 [1., 2., 3.],
1639 [0.0, 0.38268343, 0.0, 0.9238795],
1640 [2., 1., 0.5],
1641 );
1642 let i = affine_inverse(&m).unwrap();
1643 let id = mul(m, i);
1644 for (k, v) in id.iter().enumerate() {
1645 let want = if k % 5 == 0 { 1.0 } else { 0.0 };
1646 assert!((v - want).abs() < 1e-5);
1647 }
1648 }
1649
1650 #[test]
1651 fn gltf_step_is_kept_bad_cubic_rejected_unnamed_clips_get_index_names() {
1652 let base = load_gltf_file(
1653 &std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1654 .join("tests/fixtures/bodies/test-body-male.glb"),
1655 )
1656 .unwrap();
1657 assert!(base.clips.iter().all(|c| c
1658 .channels
1659 .iter()
1660 .all(|ch| ch.interpolation == Interpolation::Linear)));
1661 let step = glb_with(|d| {
1662 for a in d["animations"].as_array_mut().unwrap() {
1663 for s in a["samplers"].as_array_mut().unwrap() {
1664 s["interpolation"] = "STEP".into();
1665 }
1666 a.as_object_mut().unwrap().remove("name");
1667 }
1668 });
1669 let loaded = load_gltf_file(&step).unwrap();
1670 assert!(loaded.clips.iter().all(|c| c
1671 .channels
1672 .iter()
1673 .all(|ch| ch.interpolation == Interpolation::Step)));
1674 assert_eq!(loaded.clips[0].name, "animatie 0");
1675 assert_ne!(loaded.clips[0].name, loaded.clips[1].name);
1676 // Between keys a STEP channel holds the left key.
1677 let ch = loaded.clips[0]
1678 .channels
1679 .iter()
1680 .find(|c| c.times.len() > 2)
1681 .unwrap();
1682 let mid = (ch.times[0] + ch.times[1]) / 2.;
1683 assert_eq!(ch.sample(mid), ch.sample(ch.times[0]));
1684 // CUBICSPLINE without tangent triples is rejected explicitly.
1685 let cubic = glb_with(|d| {
1686 d["animations"][0]["samplers"][0]["interpolation"] = "CUBICSPLINE".into();
1687 });
1688 let err = load_gltf_file(&cubic).err().unwrap();
1689 assert!(err.contains("ongeldig kanaal"), "{err}");
1690 let _ = std::fs::remove_file(step);
1691 let _ = std::fs::remove_file(cubic);
1692 }
1693
1694 fn male() -> PreviewCharacter {
1695 load_gltf_file(
1696 &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),
1697 )
1698 .unwrap()
1699 }
1700
1701 fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {
1702 let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
1703 dot(d, d).sqrt()
1704 }
1705
1706 #[test]
1707 fn mirrored_static_gltf_node_flips_winding_and_keeps_outward_normals() {
1708 let dir = std::env::temp_dir().join(format!(
1709 "paperdoll-static-mirror-{}-{:?}",
1710 std::process::id(),
1711 std::thread::current().id()
1712 ));
1713 std::fs::create_dir_all(&dir).unwrap();
1714 // One triangle facing -Z (counter-clockwise seen from -Z), normal -Z.
1715 let pos: [[f32; 3]; 3] = [[0., 0., 0.], [0., 1., 0.], [1., 0., 0.]];
1716 let mut bin = Vec::new();
1717 for p in pos {
1718 for v in p {
1719 bin.extend(v.to_le_bytes());
1720 }
1721 }
1722 for _ in 0..3 {
1723 for v in [0f32, 0., -1.] {
1724 bin.extend(v.to_le_bytes());
1725 }
1726 }
1727 std::fs::write(dir.join("tri.bin"), &bin).unwrap();
1728 let gltf = serde_json::json!({
1729 "asset": {"version": "2.0"},
1730 "scene": 0,
1731 "scenes": [{"nodes": [0]}],
1732 "nodes": [{"mesh": 0, "scale": [-2.0, 1.0, 1.0], "translation": [0.0, 0.5, 0.0]}],
1733 "meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}}]}],
1734 "buffers": [{"uri": "tri.bin", "byteLength": bin.len()}],
1735 "bufferViews": [
1736 {"buffer": 0, "byteOffset": 0, "byteLength": 36},
1737 {"buffer": 0, "byteOffset": 36, "byteLength": 36}
1738 ],
1739 "accessors": [
1740 {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3",
1741 "min": [0.0, 0.0, 0.0], "max": [1.0, 1.0, 0.0]},
1742 {"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC3"}
1743 ]
1744 });
1745 std::fs::write(dir.join("tri.gltf"), gltf.to_string()).unwrap();
1746 let body = load_body_file(&dir.join("tri.gltf")).unwrap();
1747 std::fs::remove_dir_all(&dir).ok();
1748 assert!(body.is_static());
1749 assert_eq!(body.positions[2], [-2., 0.5, 0.]);
1750 assert_eq!(body.indices, vec![0, 2, 1], "mirror must flip winding");
1751 let [a, b, c] = [0, 2, 1].map(|i| body.positions[i]);
1752 let n = cross(
1753 [b[0] - a[0], b[1] - a[1], b[2] - a[2]],
1754 [c[0] - a[0], c[1] - a[1], c[2] - a[2]],
1755 );
1756 assert!(n[2] < 0., "winding still faces -Z: {n:?}");
1757 for normal in &body.normals {
1758 assert!((normal[2] + 1.).abs() < 1e-6, "{normal:?}");
1759 }
1760 assert!(normal_matrix(&trs([0.; 3], [0., 0., 0., 1.], [0., 1., 1.])).is_none());
1761 }
1762
1763 #[test]
1764 fn walk_and_attack_move_the_matching_bones() {
1765 let character = male();
1766 assert!(character.clips.iter().any(|clip| clip.name == "walk"));
1767 assert!(character.clips.iter().any(|clip| clip.name == "attack"));
1768 assert_eq!(character.joint_names.len(), 19);
1769 assert!(character.indices.len() >= 3);
1770 assert!(character.positions.len() >= 19 * 8);
1771 let leg_a = joint_world_translation(&character, "walk", 0.0, "LeftLowerLeg").unwrap();
1772 let leg_b = joint_world_translation(&character, "walk", 0.25, "LeftLowerLeg").unwrap();
1773 assert!(
1774 distance(leg_a, leg_b) > 0.05,
1775 "walk should move the lower leg, delta {}",
1776 distance(leg_a, leg_b)
1777 );
1778 let arm_a = joint_world_translation(&character, "attack", 0.0, "RightHand").unwrap();
1779 let arm_b = joint_world_translation(&character, "attack", 0.35, "RightHand").unwrap();
1780 assert!(
1781 distance(arm_a, arm_b) > 0.05,
1782 "attack should move the right hand, delta {}",
1783 distance(arm_a, arm_b)
1784 );
1785 let matrices = sample_skin_matrices(&character, "walk", 0.2);
1786 assert_eq!(matrices.len(), 19);
1787 assert!(matrices.iter().flatten().all(|value| value.is_finite()));
1788 }
1789
1790 #[test]
1791 fn female_fixture_has_the_same_clip_names() {
1792 let character = load_gltf_file(
1793 &Path::new(env!("CARGO_MANIFEST_DIR"))
1794 .join("tests/fixtures/bodies/test-body-female.glb"),
1795 )
1796 .unwrap();
1797 assert!(character.clips.iter().any(|clip| clip.name == "walk"));
1798 assert!(character.clips.iter().any(|clip| clip.name == "attack"));
1799 let a = joint_world_translation(&character, "walk", 0.0, "RightLowerLeg").unwrap();
1800 let b = joint_world_translation(&character, "walk", 0.5, "RightLowerLeg").unwrap();
1801 assert!(distance(a, b) > 0.05);
1802 }
1803
1804 #[test]
1805 fn yaw_zero_is_in_front_and_the_twelve_yaws_differ() {
1806 let front = eye_offset(0.0, 0.0, 4.0);
1807 assert!(front[2] < -3.5, "yaw 0 camera belongs on -Z, got {front:?}");
1808 assert!(front[0].abs() < 0.05);
1809 let mut seen = Vec::new();
1810 for yaw in paperdoll_yaws_deg() {
1811 let eye = eye_offset(yaw, 0.0, 4.0);
1812 assert!(seen
1813 .iter()
1814 .all(|previous: &[f32; 3]| distance(*previous, eye) > 0.5));
1815 seen.push(eye);
1816 }
1817 assert_eq!(seen.len(), 12);
1818 let low = eye_offset(0.0, -30.0, 4.0);
1819 let high = eye_offset(0.0, 30.0, 4.0);
1820 assert!(low[1] < 0.0 && high[1] > 0.0);
1821 assert_eq!(PAPERDOLL_PITCHES_DEG.len(), 5);
1822 assert_eq!(PRODUCTION_VIEWS as usize, PRODUCTION_PITCHES.len() * 12);
1823 }
1824
1825 #[test]
1826 fn controls_snap_wrap_and_keep_a_clip_change() {
1827 let mut controls = PreviewControls::default();
1828 controls.drag(-100.0, 40.0);
1829 assert!(controls.yaw_deg > 40.0);
1830 assert!(controls.pitch_deg > 10.0);
1831 controls.zoom(400.0);
1832 assert!(controls.distance < 3.4);
1833 controls.snap_yaw_index(-1);
1834 assert!((controls.yaw_deg - 330.0).abs() < 0.01);
1835 controls.snap_pitch_row(99);
1836 assert!((controls.pitch_deg - 60.0).abs() < 0.01);
1837 controls.snap_pitch_row(-4);
1838 assert!((controls.pitch_deg + 60.0).abs() < 0.01);
1839 controls.snap_pitch_row(3);
1840 assert!((controls.pitch_deg - 30.0).abs() < 0.01);
1841 controls.time = 0.4;
1842 controls.select_clip("attack");
1843 assert_eq!(controls.clip_name, "attack");
1844 assert_eq!(controls.time, 0.0);
1845 controls.time = 1.2;
1846 controls.advance(0.0, 1.0);
1847 controls.playing = true;
1848 controls.advance(0.3, 1.0);
1849 assert!((controls.time - 0.5).abs() < 0.02 || (wrap_time(1.5, 1.0) - 0.5).abs() < 0.02);
1850 }
1851}