BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / preview.rs

preview.rs

preview.rs 56.3 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 let joint_nodes: Vec<usize> = skin.joints().map(|joint| joint.index()).collect();
522 let joint_names: Vec<String> = joint_nodes
523 .iter()
524 .map(|index| nodes[*index].name.clone())
525 .collect();
526 let reader = skin.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
527 let inverse_binds = reader
528 .read_inverse_bind_matrices()
529 .map(|iter| iter.map(columns_to_mat).collect())
530 .unwrap_or_else(|| vec![identity(); joint_nodes.len()]);
531 if inverse_binds.len() != joint_nodes.len() {
532 return Err("inverse-bindmatrices en joints verschillen".to_string());
533 }
534 let mut positions = Vec::new();
535 let mut normals = Vec::new();
536 let mut source_normals_complete = true;
537 let mut joints = Vec::new();
538 let mut weights = Vec::new();
539 let mut indices = Vec::new();
540 let mut base_color = [0.7, 0.7, 0.75, 1.0];
541 let mut colors = Vec::new();
542 let mut uvs = Vec::new();
543 let mut surfaces = Vec::new();
544 if let Some(mesh) = document.meshes().next() {
545 for primitive in mesh.primitives() {
546 let prim = read_gltf_primitive(&primitive, &buffers, &images)?;
547 source_normals_complete &= prim.normals.is_some();
548 let count = prim.positions.len();
549 let first = indices.len();
550 let base = positions.len() as u32;
551 indices.extend(prim.indices.iter().map(|index| index + base));
552 positions.extend(prim.positions);
553 normals.extend(prim.normals.unwrap_or_else(|| vec![[0.0, 1.0, 0.0]; count]));
554 joints.extend(prim.joints);
555 weights.extend(prim.weights);
556 colors.extend(prim.colors);
557 uvs.extend(prim.uvs);
558 base_color = prim.color;
559 surfaces.push(BodySurface {
560 flat_normals: prim.flat_normals,
561 first,
562 count: indices.len() - first,
563 color: prim.color,
564 albedo: prim.albedo,
565 cutoff: prim.cutoff,
566 });
567 }
568 }
569 if positions.is_empty() || indices.len() < 3 {
570 return Err("het glTF-bestand heeft geen tekenbare driehoeken".to_string());
571 }
572 let mut clips = Vec::new();
573 for animation in document.animations() {
574 let mut channels = Vec::new();
575 let mut skipped = Vec::new();
576 let mut duration = 0.0_f32;
577 for channel in animation.channels() {
578 let target = channel.target().node().index();
579 let interpolation = match channel.sampler().interpolation() {
580 gltf::animation::Interpolation::Linear => Interpolation::Linear,
581 gltf::animation::Interpolation::Step => Interpolation::Step,
582 gltf::animation::Interpolation::CubicSpline => Interpolation::CubicSpline,
583 };
584 let reader = channel.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
585 let times: Vec<f32> = reader
586 .read_inputs()
587 .map(|iter| iter.collect())
588 .unwrap_or_default();
589 let Some(outputs) = reader.read_outputs() else {
590 skipped.push(format!("node {target}: geen uitvoerwaarden"));
591 continue;
592 };
593 let (property, values) = match outputs {
594 gltf::animation::util::ReadOutputs::Translations(values) => {
595 (ChannelProperty::Translation, values.flatten().collect())
596 }
597 gltf::animation::util::ReadOutputs::Scales(values) => {
598 (ChannelProperty::Scale, values.flatten().collect())
599 }
600 gltf::animation::util::ReadOutputs::Rotations(values) => (
601 ChannelProperty::Rotation,
602 values.into_f32().flatten().collect(),
603 ),
604 gltf::animation::util::ReadOutputs::MorphTargetWeights(_) => {
605 skipped.push(format!(
606 "node {target}: morph-target-gewichten (niet ondersteund)"
607 ));
608 continue;
609 }
610 };
611 let channel = AnimChannel {
612 node: target,
613 property,
614 times,
615 values,
616 interpolation,
617 };
618 if let Err(reason) = channel.validate() {
619 return Err(format!(
620 "animatie '{}': ongeldig kanaal op node {target}: {reason}",
621 animation.name().unwrap_or("naamloos")
622 ));
623 }
624 if let Some(last) = channel.times.last() {
625 duration = duration.max(*last);
626 }
627 channels.push(channel);
628 }
629 clips.push(PreviewClip {
630 name: animation
631 .name()
632 .filter(|n| !n.trim().is_empty())
633 .map(str::to_string)
634 .unwrap_or_else(|| format!("animatie {}", animation.index())),
635 duration,
636 channels,
637 skipped,
638 });
639 }
640 Ok(PreviewCharacter {
641 nodes,
642 joint_nodes,
643 joint_names,
644 inverse_binds,
645 positions,
646 normals,
647 source_normals_complete,
648 joints,
649 weights,
650 indices,
651 base_color,
652 colors,
653 uvs,
654 surfaces,
655 clips,
656 rig: BodyRig::Skinned,
657 })
658}
659
660/// One glTF triangle primitive read in its mesh-local space.
661struct GltfPrimitive {
662 positions: Vec<[f32; 3]>,
663 normals: Option<Vec<[f32; 3]>>,
664 joints: Vec<[u16; 4]>,
665 weights: Vec<[f32; 4]>,
666 colors: Vec<[f32; 4]>,
667 uvs: Vec<[f32; 2]>,
668 indices: Vec<u32>,
669 color: [f32; 4],
670 albedo: Option<std::sync::Arc<crate::equipment::Texture>>,
671 cutoff: f32,
672 flat_normals: bool,
673}
674
675fn read_gltf_primitive(
676 primitive: &gltf::Primitive<'_>,
677 buffers: &[gltf::buffer::Data],
678 images: &[gltf::image::Data],
679) -> Result<GltfPrimitive, String> {
680 if primitive.mode() != gltf::mesh::Mode::Triangles {
681 return Err("live/productie ondersteunt alleen driehoekprimitieven".into());
682 }
683 let reader = primitive.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
684 let positions: Vec<[f32; 3]> = reader
685 .read_positions()
686 .map(|iter| iter.collect())
687 .unwrap_or_default();
688 let normals: Option<Vec<[f32; 3]>> = reader.read_normals().map(|iter| iter.collect());
689 let joints: Vec<[u16; 4]> = reader
690 .read_joints(0)
691 .map(|iter| iter.into_u16().collect())
692 .unwrap_or_else(|| vec![[0, 0, 0, 0]; positions.len()]);
693 let weights: Vec<[f32; 4]> = reader
694 .read_weights(0)
695 .map(|iter| iter.into_f32().collect())
696 .unwrap_or_else(|| vec![[1.0, 0.0, 0.0, 0.0]; positions.len()]);
697 let material = primitive.material();
698 let pbr = material.pbr_metallic_roughness();
699 let texture = pbr.base_color_texture();
700 let uvset = texture.as_ref().map(|t| t.tex_coord()).unwrap_or(0);
701 let colors = reader
702 .read_colors(0)
703 .map(|v| v.into_rgba_f32().collect::<Vec<_>>())
704 .unwrap_or_else(|| vec![[1.; 4]; positions.len()]);
705 let uvs = reader
706 .read_tex_coords(uvset)
707 .map(|v| v.into_f32().collect::<Vec<_>>())
708 .unwrap_or_else(|| vec![[0.; 2]; positions.len()]);
709 let indices: Vec<u32> = match reader.read_indices() {
710 Some(read_indices) => read_indices.into_u32().collect(),
711 None => (0..positions.len() as u32).collect(),
712 };
713 let albedo = texture
714 .map(|t| {
715 let image = images
716 .get(t.texture().source().index())
717 .ok_or("ontbrekende bodytexture")?;
718 use gltf::image::Format;
719 let channels = match image.format {
720 Format::R8 => 1,
721 Format::R8G8 => 2,
722 Format::R8G8B8 => 3,
723 Format::R8G8B8A8 => 4,
724 _ => return Err("bodytexture moet 8-bit zijn".to_string()),
725 };
726 if image.width > 2048 || image.height > 2048 {
727 return Err("bodytexture groter dan 2048×2048".into());
728 }
729 let mut rgba = Vec::new();
730 for p in image.pixels.chunks_exact(channels) {
731 rgba.extend_from_slice(&match channels {
732 1 => [p[0], p[0], p[0], 255],
733 2 => [p[0], p[0], p[0], p[1]],
734 3 => [p[0], p[1], p[2], 255],
735 _ => [p[0], p[1], p[2], p[3]],
736 });
737 }
738 use std::hash::{Hash, Hasher};
739 let mut hash = std::collections::hash_map::DefaultHasher::new();
740 rgba.hash(&mut hash);
741 Ok(std::sync::Arc::new(crate::equipment::Texture {
742 content_hash: hash.finish(),
743 width: image.width,
744 height: image.height,
745 rgba,
746 }))
747 })
748 .transpose()?;
749 Ok(GltfPrimitive {
750 flat_normals: normals.is_none(),
751 positions,
752 normals,
753 joints,
754 weights,
755 colors,
756 uvs,
757 indices,
758 color: pbr.base_color_factor(),
759 albedo,
760 cutoff: if material.alpha_mode() == gltf::material::AlphaMode::Mask {
761 material.alpha_cutoff().unwrap_or(0.5)
762 } else {
763 0.001
764 },
765 })
766}
767
768/// glTF without a skin: every mesh instance of the default scene, with its
769/// node's rest world transform baked into positions (and the inverse-
770/// transpose into normals) exactly once; mirrored transforms flip winding.
771/// Animations are not applied (no skeleton is invented).
772fn load_static_gltf(
773 document: &gltf::Document,
774 buffers: &[gltf::buffer::Data],
775 images: &[gltf::image::Data],
776 nodes: &[SceneNode],
777) -> Result<PreviewCharacter, String> {
778 let mut roots: Vec<usize> = match document
779 .default_scene()
780 .or_else(|| document.scenes().next())
781 {
782 Some(scene) => scene.nodes().map(|n| n.index()).collect(),
783 None => (0..nodes.len())
784 .filter(|i| nodes[*i].parent.is_none())
785 .collect(),
786 };
787 let children: Vec<Vec<usize>> = document
788 .nodes()
789 .map(|n| n.children().map(|c| c.index()).collect())
790 .collect();
791 let mut acc = StaticAcc::default();
792 let mut stack: Vec<(usize, Mat4, usize)> =
793 roots.drain(..).rev().map(|r| (r, identity(), 0)).collect();
794 let mut visited = 0usize;
795 while let Some((index, parent, depth)) = stack.pop() {
796 visited += 1;
797 if depth > nodes.len() || visited > nodes.len() * 4 + 16 {
798 return Err("glTF-nodehiërarchie bevat een cyclus".into());
799 }
800 let node = nodes.get(index).ok_or("ongeldige glTF-node")?;
801 let world = mul(parent, trs(node.translation, node.rotation, node.scale));
802 if !world.iter().all(|x| x.is_finite()) {
803 return Err("glTF-node heeft een niet-eindige transformatie".into());
804 }
805 if let Some(mesh) = document.nodes().nth(index).and_then(|n| n.mesh()) {
806 let normal_matrix =
807 normal_matrix(&world).ok_or("glTF-node heeft een singuliere transformatie")?;
808 let mirrored = det3(&world) < 0.0;
809 for primitive in mesh.primitives() {
810 let prim = read_gltf_primitive(&primitive, buffers, images)?;
811 let positions: Vec<[f32; 3]> = prim
812 .positions
813 .iter()
814 .map(|p| transform_point3(&world, *p))
815 .collect();
816 let normals: Vec<[f32; 3]> = match &prim.normals {
817 Some(normals) => normals
818 .iter()
819 .map(|n| normalize3(mat3_mul(&normal_matrix, *n)))
820 .collect(),
821 None => vec![[0., 1., 0.]; positions.len()],
822 };
823 let mut indices = prim.indices.clone();
824 if !indices.len().is_multiple_of(3) {
825 return Err("glTF-primitive heeft een onvolledige driehoek".into());
826 }
827 if mirrored {
828 for tri in indices.chunks_exact_mut(3) {
829 tri.swap(1, 2);
830 }
831 }
832 acc.push(
833 &positions,
834 &normals,
835 &prim.uvs,
836 Some(&prim.colors),
837 &indices,
838 prim.color,
839 prim.albedo,
840 prim.cutoff,
841 prim.flat_normals,
842 )?;
843 }
844 }
845 for child in children.get(index).into_iter().flatten().rev() {
846 stack.push((*child, world, depth + 1));
847 }
848 }
849 let animations = document.animations().count();
850 let reason = if animations == 0 {
851 "glTF zonder skin: statische pose, geen skeleton of animaties".to_string()
852 } else {
853 format!(
854 "glTF zonder skin: statische pose; {animations} animatie(s) niet toegepast (geen skeleton)"
855 )
856 };
857 acc.finish("glTF", reason)
858}
859
860fn transform_point3(m: &Mat4, p: [f32; 3]) -> [f32; 3] {
861 [
862 m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
863 m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
864 m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14],
865 ]
866}
867
868fn det3(m: &Mat4) -> f32 {
869 m[0] * (m[5] * m[10] - m[9] * m[6]) - m[4] * (m[1] * m[10] - m[9] * m[2])
870 + m[8] * (m[1] * m[6] - m[5] * m[2])
871}
872
873/// Inverse-transpose of the upper 3×3 (row-major result for `mat3_mul`).
874fn normal_matrix(m: &Mat4) -> Option<[[f32; 3]; 3]> {
875 let det = det3(m);
876 if !det.is_finite() || det.abs() < 1e-12 {
877 return None;
878 }
879 // a[r][c] = element at row r, column c of the column-major matrix.
880 let a = |r: usize, c: usize| m[c * 4 + r];
881 let cof = |r: usize, c: usize| {
882 let rows: Vec<usize> = (0..3).filter(|x| *x != r).collect();
883 let cols: Vec<usize> = (0..3).filter(|x| *x != c).collect();
884 let minor =
885 a(rows[0], cols[0]) * a(rows[1], cols[1]) - a(rows[0], cols[1]) * a(rows[1], cols[0]);
886 if (r + c).is_multiple_of(2) {
887 minor
888 } else {
889 -minor
890 }
891 };
892 // inverse-transpose = cofactor matrix / det.
893 let mut out = [[0.; 3]; 3];
894 for (r, row) in out.iter_mut().enumerate() {
895 for (c, value) in row.iter_mut().enumerate() {
896 *value = cof(r, c) / det;
897 }
898 }
899 Some(out)
900}
901
902fn mat3_mul(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
903 [
904 m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
905 m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
906 m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
907 ]
908}
909
910pub fn sample_skin_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {
911 let locals = sample_local_matrices(character, clip_name, time);
912 let worlds = world_matrices(&character.nodes, &locals);
913 character
914 .joint_nodes
915 .iter()
916 .enumerate()
917 .map(|(joint, node)| mul(worlds[*node], character.inverse_binds[joint]))
918 .collect()
919}
920
921/// Stable hierarchy path includes node indices to disambiguate duplicate names.
922pub fn joint_paths(character: &PreviewCharacter) -> Vec<String> {
923 character
924 .joint_nodes
925 .iter()
926 .map(|&node| {
927 let mut names = Vec::new();
928 let mut cursor = Some(node);
929 while let Some(index) = cursor {
930 let item = &character.nodes[index];
931 names.push(format!("{}[{}]", item.name, index));
932 cursor = item.parent;
933 if names.len() > character.nodes.len() {
934 break;
935 }
936 }
937 names.reverse();
938 names.join("/")
939 })
940 .collect()
941}
942
943/// Rest-world joint transform, never selected by a possibly empty clip name.
944/// Bounded parent walk rejects malformed cycles/indices instead of recursing.
945pub fn rest_joint_world(character: &PreviewCharacter, joint: usize) -> Option<Mat4> {
946 let mut node = Some(*character.joint_nodes.get(joint)?);
947 let mut chain = Vec::new();
948 while let Some(i) = node {
949 if chain.len() >= character.nodes.len() {
950 return None;
951 }
952 let n = character.nodes.get(i)?;
953 chain.push(i);
954 node = n.parent;
955 }
956 let mut world = identity();
957 for i in chain.into_iter().rev() {
958 let n = &character.nodes[i];
959 world = mul(world, trs(n.translation, n.rotation, n.scale));
960 }
961 world.iter().all(|n| n.is_finite()).then_some(world)
962}
963
964pub fn animated_joint_world(
965 character: &PreviewCharacter,
966 clip: &str,
967 time: f32,
968 joint: usize,
969) -> Option<Mat4> {
970 let locals = sample_local_matrices(character, clip, time);
971 let worlds = world_matrices(&character.nodes, &locals);
972 character
973 .joint_nodes
974 .get(joint)
975 .and_then(|node| worlds.get(*node))
976 .copied()
977}
978
979pub fn joint_world_translation(
980 character: &PreviewCharacter,
981 clip_name: &str,
982 time: f32,
983 joint_name: &str,
984) -> Option<[f32; 3]> {
985 let joint = character
986 .joint_names
987 .iter()
988 .position(|name| name == joint_name)?;
989 let node = character.joint_nodes[joint];
990 let locals = sample_local_matrices(character, clip_name, time);
991 let worlds = world_matrices(&character.nodes, &locals);
992 Some([worlds[node][12], worlds[node][13], worlds[node][14]])
993}
994
995pub fn clip_duration(character: &PreviewCharacter, clip_name: &str) -> f32 {
996 character
997 .clips
998 .iter()
999 .find(|clip| {
1000 clip.name == clip_name || clip.name.to_lowercase().contains(&clip_name.to_lowercase())
1001 })
1002 .map(|clip| clip.duration)
1003 .unwrap_or(0.0)
1004}
1005
1006pub fn eye_offset(yaw_deg: f32, pitch_deg: f32, distance: f32) -> [f32; 3] {
1007 let yaw = yaw_deg.to_radians();
1008 let pitch = pitch_deg.to_radians();
1009 [
1010 -distance * pitch.cos() * yaw.sin(),
1011 distance * pitch.sin(),
1012 -distance * pitch.cos() * yaw.cos(),
1013 ]
1014}
1015
1016pub fn paperdoll_yaws_deg() -> [f32; PAPERDOLL_YAW_COUNT] {
1017 let mut yaws = [0.0; PAPERDOLL_YAW_COUNT];
1018 for (index, yaw) in yaws.iter_mut().enumerate() {
1019 *yaw = index as f32 * 30.0;
1020 }
1021 yaws
1022}
1023
1024fn sample_local_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {
1025 let mut translation: Vec<[f32; 3]> = character
1026 .nodes
1027 .iter()
1028 .map(|node| node.translation)
1029 .collect();
1030 let mut rotation: Vec<[f32; 4]> = character.nodes.iter().map(|node| node.rotation).collect();
1031 let mut scale: Vec<[f32; 3]> = character.nodes.iter().map(|node| node.scale).collect();
1032 if let Some(clip) = find_clip(&character.clips, clip_name) {
1033 let time = wrap_time(time, clip.duration);
1034 for channel in &clip.channels {
1035 if channel.node >= character.nodes.len() || channel.validate().is_err() {
1036 continue;
1037 }
1038 let v = channel.sample(time);
1039 match channel.property {
1040 ChannelProperty::Translation => translation[channel.node] = [v[0], v[1], v[2]],
1041 ChannelProperty::Scale => scale[channel.node] = [v[0], v[1], v[2]],
1042 ChannelProperty::Rotation => rotation[channel.node] = [v[0], v[1], v[2], v[3]],
1043 }
1044 }
1045 }
1046 translation
1047 .into_iter()
1048 .zip(rotation)
1049 .zip(scale)
1050 .map(|((translation, rotation), scale)| trs(translation, rotation, scale))
1051 .collect()
1052}
1053
1054fn world_matrices(nodes: &[SceneNode], locals: &[Mat4]) -> Vec<Mat4> {
1055 fn world(
1056 index: usize,
1057 nodes: &[SceneNode],
1058 locals: &[Mat4],
1059 worlds: &mut [Option<Mat4>],
1060 depth: usize,
1061 ) -> Mat4 {
1062 if let Some(matrix) = worlds[index] {
1063 return matrix;
1064 }
1065 if depth > nodes.len() {
1066 return locals[index];
1067 }
1068 let matrix = match nodes[index].parent {
1069 Some(parent) => mul(
1070 world(parent, nodes, locals, worlds, depth + 1),
1071 locals[index],
1072 ),
1073 None => locals[index],
1074 };
1075 worlds[index] = Some(matrix);
1076 matrix
1077 }
1078 let mut cached = vec![None; nodes.len()];
1079 (0..nodes.len())
1080 .map(|index| world(index, nodes, locals, &mut cached, 0))
1081 .collect()
1082}
1083
1084fn key_blend(times: &[f32], time: f32) -> (usize, usize, f32) {
1085 if times.len() == 1 || time <= times[0] {
1086 return (0, 0, 0.0);
1087 }
1088 if time >= times[times.len() - 1] {
1089 let last = times.len() - 1;
1090 return (last, last, 0.0);
1091 }
1092 let mut index = 0;
1093 while index + 1 < times.len() && times[index + 1] < time {
1094 index += 1;
1095 }
1096 let span = times[index + 1] - times[index];
1097 let blend = if span.abs() < 1e-6 {
1098 0.0
1099 } else {
1100 (time - times[index]) / span
1101 };
1102 (index, index + 1, blend)
1103}
1104
1105fn slerp(a: [f32; 4], mut b: [f32; 4], t: f32) -> [f32; 4] {
1106 let mut dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
1107 if dot < 0.0 {
1108 dot = -dot;
1109 b = [-b[0], -b[1], -b[2], -b[3]];
1110 }
1111 if dot > 0.9995 {
1112 return normalize4([
1113 a[0] + (b[0] - a[0]) * t,
1114 a[1] + (b[1] - a[1]) * t,
1115 a[2] + (b[2] - a[2]) * t,
1116 a[3] + (b[3] - a[3]) * t,
1117 ]);
1118 }
1119 let theta = dot.clamp(-1.0, 1.0).acos();
1120 let sin_theta = theta.sin();
1121 let w1 = ((1.0 - t) * theta).sin() / sin_theta;
1122 let w2 = (t * theta).sin() / sin_theta;
1123 [
1124 a[0] * w1 + b[0] * w2,
1125 a[1] * w1 + b[1] * w2,
1126 a[2] * w1 + b[2] * w2,
1127 a[3] * w1 + b[3] * w2,
1128 ]
1129}
1130
1131fn normalize4(value: [f32; 4]) -> [f32; 4] {
1132 let length =
1133 (value[0] * value[0] + value[1] * value[1] + value[2] * value[2] + value[3] * value[3])
1134 .sqrt();
1135 if length < 1e-8 {
1136 [0.0, 0.0, 0.0, 1.0]
1137 } else {
1138 [
1139 value[0] / length,
1140 value[1] / length,
1141 value[2] / length,
1142 value[3] / length,
1143 ]
1144 }
1145}
1146
1147fn trs(translation: [f32; 3], rotation: [f32; 4], scale: [f32; 3]) -> Mat4 {
1148 let mut matrix = quat_to_mat(normalize4(rotation));
1149 matrix[0] *= scale[0];
1150 matrix[1] *= scale[0];
1151 matrix[2] *= scale[0];
1152 matrix[4] *= scale[1];
1153 matrix[5] *= scale[1];
1154 matrix[6] *= scale[1];
1155 matrix[8] *= scale[2];
1156 matrix[9] *= scale[2];
1157 matrix[10] *= scale[2];
1158 matrix[12] = translation[0];
1159 matrix[13] = translation[1];
1160 matrix[14] = translation[2];
1161 matrix
1162}
1163
1164fn quat_to_mat(q: [f32; 4]) -> Mat4 {
1165 let (x, y, z, w) = (q[0], q[1], q[2], q[3]);
1166 let xx = x * x;
1167 let yy = y * y;
1168 let zz = z * z;
1169 let xy = x * y;
1170 let xz = x * z;
1171 let yz = y * z;
1172 let wx = w * x;
1173 let wy = w * y;
1174 let wz = w * z;
1175 [
1176 1.0 - 2.0 * (yy + zz),
1177 2.0 * (xy + wz),
1178 2.0 * (xz - wy),
1179 0.0,
1180 2.0 * (xy - wz),
1181 1.0 - 2.0 * (xx + zz),
1182 2.0 * (yz + wx),
1183 0.0,
1184 2.0 * (xz + wy),
1185 2.0 * (yz - wx),
1186 1.0 - 2.0 * (xx + yy),
1187 0.0,
1188 0.0,
1189 0.0,
1190 0.0,
1191 1.0,
1192 ]
1193}
1194
1195pub fn identity() -> Mat4 {
1196 [
1197 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,
1198 ]
1199}
1200
1201pub fn mul(left: Mat4, right: Mat4) -> Mat4 {
1202 let mut out = [0.0; 16];
1203 for column in 0..4 {
1204 for row in 0..4 {
1205 out[column * 4 + row] = left[row] * right[column * 4]
1206 + left[4 + row] * right[column * 4 + 1]
1207 + left[8 + row] * right[column * 4 + 2]
1208 + left[12 + row] * right[column * 4 + 3];
1209 }
1210 }
1211 out
1212}
1213
1214fn columns_to_mat(columns: [[f32; 4]; 4]) -> Mat4 {
1215 let mut matrix = [0.0; 16];
1216 for column in 0..4 {
1217 for row in 0..4 {
1218 matrix[column * 4 + row] = columns[column][row];
1219 }
1220 }
1221 matrix
1222}
1223
1224pub fn wrap_time(time: f32, duration: f32) -> f32 {
1225 if duration <= 1e-5 {
1226 return 0.0;
1227 }
1228 let mut wrapped = time % duration;
1229 if wrapped < 0.0 {
1230 wrapped += duration;
1231 }
1232 wrapped
1233}
1234
1235fn wrap_deg(mut degrees: f32) -> f32 {
1236 degrees %= 360.0;
1237 if degrees < 0.0 {
1238 degrees += 360.0;
1239 }
1240 degrees
1241}
1242
1243pub fn view_matrix(eye: [f32; 3], target: [f32; 3]) -> Mat4 {
1244 let forward = normalize3([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]);
1245 let up = [0.0, 1.0, 0.0];
1246 let mut side = normalize3(cross(forward, up));
1247 if side[0].abs() + side[1].abs() + side[2].abs() < 1e-4 {
1248 side = [1.0, 0.0, 0.0];
1249 }
1250 let up = cross(side, forward);
1251 [
1252 side[0],
1253 up[0],
1254 -forward[0],
1255 0.0,
1256 side[1],
1257 up[1],
1258 -forward[1],
1259 0.0,
1260 side[2],
1261 up[2],
1262 -forward[2],
1263 0.0,
1264 -dot(side, eye),
1265 -dot(up, eye),
1266 dot(forward, eye),
1267 1.0,
1268 ]
1269}
1270
1271pub fn perspective(fov_y_deg: f32, aspect: f32, near: f32, far: f32) -> Mat4 {
1272 let f = 1.0 / (fov_y_deg.to_radians() * 0.5).tan();
1273 let aspect = if aspect.abs() < 1e-4 { 1.0 } else { aspect };
1274 let nf = 1.0 / (near - far);
1275 [
1276 f / aspect,
1277 0.0,
1278 0.0,
1279 0.0,
1280 0.0,
1281 f,
1282 0.0,
1283 0.0,
1284 0.0,
1285 0.0,
1286 (far + near) * nf,
1287 -1.0,
1288 0.0,
1289 0.0,
1290 2.0 * far * near * nf,
1291 0.0,
1292 ]
1293}
1294
1295fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
1296 [
1297 a[1] * b[2] - a[2] * b[1],
1298 a[2] * b[0] - a[0] * b[2],
1299 a[0] * b[1] - a[1] * b[0],
1300 ]
1301}
1302
1303fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
1304 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
1305}
1306
1307fn normalize3(value: [f32; 3]) -> [f32; 3] {
1308 let length = dot(value, value).sqrt();
1309 if length < 1e-8 {
1310 [0.0, 0.0, 1.0]
1311 } else {
1312 [value[0] / length, value[1] / length, value[2] / length]
1313 }
1314}
1315
1316#[cfg(test)]
1317mod tests {
1318 use super::*;
1319
1320 fn chan(
1321 property: ChannelProperty,
1322 interpolation: Interpolation,
1323 times: &[f32],
1324 values: &[f32],
1325 ) -> AnimChannel {
1326 AnimChannel {
1327 node: 0,
1328 property,
1329 times: times.to_vec(),
1330 values: values.to_vec(),
1331 interpolation,
1332 }
1333 }
1334
1335 #[test]
1336 fn step_linear_and_cubic_interpolation_follow_gltf() {
1337 let lin = chan(
1338 ChannelProperty::Translation,
1339 Interpolation::Linear,
1340 &[0., 1.],
1341 &[0., 0., 0., 2., 4., 6.],
1342 );
1343 assert_eq!(lin.sample(0.25), vec![0.5, 1., 1.5]);
1344 assert_eq!(lin.sample(-1.), vec![0., 0., 0.]);
1345 assert_eq!(lin.sample(5.), vec![2., 4., 6.]);
1346 let step = Interpolation::Step;
1347 let st = chan(
1348 ChannelProperty::Translation,
1349 step,
1350 &[0., 1.],
1351 &[0., 0., 0., 2., 4., 6.],
1352 );
1353 assert_eq!(st.sample(0.99), vec![0., 0., 0.]);
1354 assert_eq!(st.sample(1.), vec![2., 4., 6.]);
1355 // Cubic: (in, value, out) per key; zero tangents give smoothstep.
1356 let cubic = chan(
1357 ChannelProperty::Translation,
1358 Interpolation::CubicSpline,
1359 &[0., 2.],
1360 &[
1361 9., 9., 9., 0., 0., 0., 0., 0., 0., 0., 0., 0., 10., 10., 10., 9., 9., 9.,
1362 ],
1363 );
1364 cubic.validate().unwrap();
1365 let v = cubic.sample(0.5); // t = 0.25 -> h01 = 0.15625
1366 assert!((v[0] - 1.5625).abs() < 1e-5, "{v:?}");
1367 // Tangents are scaled by the key interval: out-tangent 1/s over 2 s.
1368 let sloped = chan(
1369 ChannelProperty::Translation,
1370 Interpolation::CubicSpline,
1371 &[0., 2.],
1372 &[
1373 0., 0., 0., 0., 0., 0., 1., 0., 0., 0., 0., 0., 2., 0., 0., 0., 0., 0.,
1374 ],
1375 );
1376 let v = sloped.sample(1.); // t=.5: h01*2 + h10*2*1 = 1 + 0.25
1377 assert!((v[0] - 1.25).abs() < 1e-5, "{v:?}");
1378 // Cubic rotation stays unit length.
1379 let q = chan(
1380 ChannelProperty::Rotation,
1381 Interpolation::CubicSpline,
1382 &[0., 1.],
1383 &[
1384 0.,
1385 0.,
1386 0.,
1387 0.,
1388 0.,
1389 0.,
1390 0.,
1391 1.,
1392 0.,
1393 0.,
1394 0.,
1395 0.,
1396 0.,
1397 0.,
1398 0.,
1399 0.,
1400 0.,
1401 0.,
1402 std::f32::consts::FRAC_1_SQRT_2,
1403 std::f32::consts::FRAC_1_SQRT_2,
1404 0.,
1405 0.,
1406 0.,
1407 0.,
1408 ],
1409 );
1410 let r = q.sample(0.5);
1411 let len: f32 = r.iter().map(|x| x * x).sum::<f32>().sqrt();
1412 assert!((len - 1.).abs() < 1e-4);
1413 // Validation rejects malformed channels.
1414 assert!(chan(
1415 ChannelProperty::Translation,
1416 Interpolation::CubicSpline,
1417 &[0., 1.],
1418 &[0.; 6]
1419 )
1420 .validate()
1421 .is_err());
1422 assert!(chan(
1423 ChannelProperty::Translation,
1424 Interpolation::Linear,
1425 &[1., 0.],
1426 &[0.; 6]
1427 )
1428 .validate()
1429 .is_err());
1430 assert!(chan(
1431 ChannelProperty::Translation,
1432 Interpolation::Linear,
1433 &[0., f32::NAN],
1434 &[0.; 6]
1435 )
1436 .validate()
1437 .is_err());
1438 assert!(chan(
1439 ChannelProperty::Scale,
1440 Interpolation::Linear,
1441 &[0.],
1442 &[f32::INFINITY, 0., 0.]
1443 )
1444 .validate()
1445 .is_err());
1446 }
1447
1448 #[test]
1449 fn clip_lookup_prefers_exact_name() {
1450 let clip = |name: &str| PreviewClip {
1451 name: name.into(),
1452 duration: 1.,
1453 channels: Vec::new(),
1454 skipped: Vec::new(),
1455 };
1456 let clips = vec![clip("walk_fast"), clip("walk"), clip("Attack")];
1457 assert_eq!(find_clip(&clips, "walk").unwrap().name, "walk");
1458 assert_eq!(find_clip(&clips, "attack").unwrap().name, "Attack");
1459 assert!(find_clip(&clips, "run").is_none());
1460 }
1461
1462 fn glb_with(edit: impl Fn(&mut serde_json::Value)) -> std::path::PathBuf {
1463 let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1464 .join("tests/fixtures/bodies/test-body-male.glb");
1465 let bytes = std::fs::read(src).unwrap();
1466 let json_len = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize;
1467 let mut doc: serde_json::Value = serde_json::from_slice(&bytes[20..20 + json_len]).unwrap();
1468 edit(&mut doc);
1469 let mut json = serde_json::to_vec(&doc).unwrap();
1470 while !json.len().is_multiple_of(4) {
1471 json.push(b' ');
1472 }
1473 let rest = &bytes[20 + json_len..];
1474 let mut out = Vec::new();
1475 out.extend_from_slice(&bytes[0..8]);
1476 out.extend_from_slice(&((12 + 8 + json.len() + rest.len()) as u32).to_le_bytes());
1477 out.extend_from_slice(&(json.len() as u32).to_le_bytes());
1478 out.extend_from_slice(b"JSON");
1479 out.extend_from_slice(&json);
1480 out.extend_from_slice(rest);
1481 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
1482 let path = std::env::temp_dir().join(format!(
1483 "ppd-anim-{}-{}.glb",
1484 std::process::id(),
1485 N.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1486 ));
1487 std::fs::write(&path, out).unwrap();
1488 path
1489 }
1490
1491 #[test]
1492 fn gltf_step_is_kept_bad_cubic_rejected_unnamed_clips_get_index_names() {
1493 let base = load_gltf_file(
1494 &std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1495 .join("tests/fixtures/bodies/test-body-male.glb"),
1496 )
1497 .unwrap();
1498 assert!(base.clips.iter().all(|c| c
1499 .channels
1500 .iter()
1501 .all(|ch| ch.interpolation == Interpolation::Linear)));
1502 let step = glb_with(|d| {
1503 for a in d["animations"].as_array_mut().unwrap() {
1504 for s in a["samplers"].as_array_mut().unwrap() {
1505 s["interpolation"] = "STEP".into();
1506 }
1507 a.as_object_mut().unwrap().remove("name");
1508 }
1509 });
1510 let loaded = load_gltf_file(&step).unwrap();
1511 assert!(loaded.clips.iter().all(|c| c
1512 .channels
1513 .iter()
1514 .all(|ch| ch.interpolation == Interpolation::Step)));
1515 assert_eq!(loaded.clips[0].name, "animatie 0");
1516 assert_ne!(loaded.clips[0].name, loaded.clips[1].name);
1517 // Between keys a STEP channel holds the left key.
1518 let ch = loaded.clips[0]
1519 .channels
1520 .iter()
1521 .find(|c| c.times.len() > 2)
1522 .unwrap();
1523 let mid = (ch.times[0] + ch.times[1]) / 2.;
1524 assert_eq!(ch.sample(mid), ch.sample(ch.times[0]));
1525 // CUBICSPLINE without tangent triples is rejected explicitly.
1526 let cubic = glb_with(|d| {
1527 d["animations"][0]["samplers"][0]["interpolation"] = "CUBICSPLINE".into();
1528 });
1529 let err = load_gltf_file(&cubic).err().unwrap();
1530 assert!(err.contains("ongeldig kanaal"), "{err}");
1531 let _ = std::fs::remove_file(step);
1532 let _ = std::fs::remove_file(cubic);
1533 }
1534
1535 fn male() -> PreviewCharacter {
1536 load_gltf_file(
1537 &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),
1538 )
1539 .unwrap()
1540 }
1541
1542 fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {
1543 let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
1544 dot(d, d).sqrt()
1545 }
1546
1547 #[test]
1548 fn mirrored_static_gltf_node_flips_winding_and_keeps_outward_normals() {
1549 let dir = std::env::temp_dir().join(format!(
1550 "paperdoll-static-mirror-{}-{:?}",
1551 std::process::id(),
1552 std::thread::current().id()
1553 ));
1554 std::fs::create_dir_all(&dir).unwrap();
1555 // One triangle facing -Z (counter-clockwise seen from -Z), normal -Z.
1556 let pos: [[f32; 3]; 3] = [[0., 0., 0.], [0., 1., 0.], [1., 0., 0.]];
1557 let mut bin = Vec::new();
1558 for p in pos {
1559 for v in p {
1560 bin.extend(v.to_le_bytes());
1561 }
1562 }
1563 for _ in 0..3 {
1564 for v in [0f32, 0., -1.] {
1565 bin.extend(v.to_le_bytes());
1566 }
1567 }
1568 std::fs::write(dir.join("tri.bin"), &bin).unwrap();
1569 let gltf = serde_json::json!({
1570 "asset": {"version": "2.0"},
1571 "scene": 0,
1572 "scenes": [{"nodes": [0]}],
1573 "nodes": [{"mesh": 0, "scale": [-2.0, 1.0, 1.0], "translation": [0.0, 0.5, 0.0]}],
1574 "meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}}]}],
1575 "buffers": [{"uri": "tri.bin", "byteLength": bin.len()}],
1576 "bufferViews": [
1577 {"buffer": 0, "byteOffset": 0, "byteLength": 36},
1578 {"buffer": 0, "byteOffset": 36, "byteLength": 36}
1579 ],
1580 "accessors": [
1581 {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3",
1582 "min": [0.0, 0.0, 0.0], "max": [1.0, 1.0, 0.0]},
1583 {"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC3"}
1584 ]
1585 });
1586 std::fs::write(dir.join("tri.gltf"), gltf.to_string()).unwrap();
1587 let body = load_body_file(&dir.join("tri.gltf")).unwrap();
1588 std::fs::remove_dir_all(&dir).ok();
1589 assert!(body.is_static());
1590 assert_eq!(body.positions[2], [-2., 0.5, 0.]);
1591 assert_eq!(body.indices, vec![0, 2, 1], "mirror must flip winding");
1592 let [a, b, c] = [0, 2, 1].map(|i| body.positions[i]);
1593 let n = cross(
1594 [b[0] - a[0], b[1] - a[1], b[2] - a[2]],
1595 [c[0] - a[0], c[1] - a[1], c[2] - a[2]],
1596 );
1597 assert!(n[2] < 0., "winding still faces -Z: {n:?}");
1598 for normal in &body.normals {
1599 assert!((normal[2] + 1.).abs() < 1e-6, "{normal:?}");
1600 }
1601 assert!(normal_matrix(&trs([0.; 3], [0., 0., 0., 1.], [0., 1., 1.])).is_none());
1602 }
1603
1604 #[test]
1605 fn walk_and_attack_move_the_matching_bones() {
1606 let character = male();
1607 assert!(character.clips.iter().any(|clip| clip.name == "walk"));
1608 assert!(character.clips.iter().any(|clip| clip.name == "attack"));
1609 assert_eq!(character.joint_names.len(), 19);
1610 assert!(character.indices.len() >= 3);
1611 assert!(character.positions.len() >= 19 * 8);
1612 let leg_a = joint_world_translation(&character, "walk", 0.0, "LeftLowerLeg").unwrap();
1613 let leg_b = joint_world_translation(&character, "walk", 0.25, "LeftLowerLeg").unwrap();
1614 assert!(
1615 distance(leg_a, leg_b) > 0.05,
1616 "walk should move the lower leg, delta {}",
1617 distance(leg_a, leg_b)
1618 );
1619 let arm_a = joint_world_translation(&character, "attack", 0.0, "RightHand").unwrap();
1620 let arm_b = joint_world_translation(&character, "attack", 0.35, "RightHand").unwrap();
1621 assert!(
1622 distance(arm_a, arm_b) > 0.05,
1623 "attack should move the right hand, delta {}",
1624 distance(arm_a, arm_b)
1625 );
1626 let matrices = sample_skin_matrices(&character, "walk", 0.2);
1627 assert_eq!(matrices.len(), 19);
1628 assert!(matrices.iter().flatten().all(|value| value.is_finite()));
1629 }
1630
1631 #[test]
1632 fn female_fixture_has_the_same_clip_names() {
1633 let character = load_gltf_file(
1634 &Path::new(env!("CARGO_MANIFEST_DIR"))
1635 .join("tests/fixtures/bodies/test-body-female.glb"),
1636 )
1637 .unwrap();
1638 assert!(character.clips.iter().any(|clip| clip.name == "walk"));
1639 assert!(character.clips.iter().any(|clip| clip.name == "attack"));
1640 let a = joint_world_translation(&character, "walk", 0.0, "RightLowerLeg").unwrap();
1641 let b = joint_world_translation(&character, "walk", 0.5, "RightLowerLeg").unwrap();
1642 assert!(distance(a, b) > 0.05);
1643 }
1644
1645 #[test]
1646 fn yaw_zero_is_in_front_and_the_twelve_yaws_differ() {
1647 let front = eye_offset(0.0, 0.0, 4.0);
1648 assert!(front[2] < -3.5, "yaw 0 camera belongs on -Z, got {front:?}");
1649 assert!(front[0].abs() < 0.05);
1650 let mut seen = Vec::new();
1651 for yaw in paperdoll_yaws_deg() {
1652 let eye = eye_offset(yaw, 0.0, 4.0);
1653 assert!(seen
1654 .iter()
1655 .all(|previous: &[f32; 3]| distance(*previous, eye) > 0.5));
1656 seen.push(eye);
1657 }
1658 assert_eq!(seen.len(), 12);
1659 let low = eye_offset(0.0, -30.0, 4.0);
1660 let high = eye_offset(0.0, 30.0, 4.0);
1661 assert!(low[1] < 0.0 && high[1] > 0.0);
1662 assert_eq!(PAPERDOLL_PITCHES_DEG.len(), 5);
1663 assert_eq!(PRODUCTION_VIEWS as usize, PRODUCTION_PITCHES.len() * 12);
1664 }
1665
1666 #[test]
1667 fn controls_snap_wrap_and_keep_a_clip_change() {
1668 let mut controls = PreviewControls::default();
1669 controls.drag(-100.0, 40.0);
1670 assert!(controls.yaw_deg > 40.0);
1671 assert!(controls.pitch_deg > 10.0);
1672 controls.zoom(400.0);
1673 assert!(controls.distance < 3.4);
1674 controls.snap_yaw_index(-1);
1675 assert!((controls.yaw_deg - 330.0).abs() < 0.01);
1676 controls.snap_pitch_row(99);
1677 assert!((controls.pitch_deg - 60.0).abs() < 0.01);
1678 controls.snap_pitch_row(-4);
1679 assert!((controls.pitch_deg + 60.0).abs() < 0.01);
1680 controls.snap_pitch_row(3);
1681 assert!((controls.pitch_deg - 30.0).abs() < 0.01);
1682 controls.time = 0.4;
1683 controls.select_clip("attack");
1684 assert_eq!(controls.clip_name, "attack");
1685 assert_eq!(controls.time, 0.0);
1686 controls.time = 1.2;
1687 controls.advance(0.0, 1.0);
1688 controls.playing = true;
1689 controls.advance(0.3, 1.0);
1690 assert!((controls.time - 0.5).abs() < 0.02 || (wrap_time(1.5, 1.0) - 0.5).abs() < 0.02);
1691 }
1692}