BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / preview.rs

preview.rs

preview.rs 24.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 three
5//! pitches and twelve yaws. The character forward axis of the test bodies is -Z,
6//! so yaw 0 places the camera on -Z, looking at the body.
7
8use std::path::Path;
9
10pub const PAPERDOLL_PITCHES_DEG: [f32; 3] = [-30.0, 0.0, 30.0];
11pub const PAPERDOLL_YAW_COUNT: usize = 12;
12pub const MAX_JOINTS: usize = 128;
13
14pub type Mat4 = [f32; 16];
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum PreviewSex {
18 Male,
19 Female,
20}
21
22#[derive(Debug, Clone)]
23pub struct PreviewControls {
24 pub sex: PreviewSex,
25 pub clip_name: String,
26 pub time: f32,
27 pub playing: bool,
28 pub yaw_deg: f32,
29 pub pitch_deg: f32,
30 pub distance: f32,
31}
32
33impl Default for PreviewControls {
34 fn default() -> Self {
35 Self {
36 sex: PreviewSex::Male,
37 clip_name: "walk".to_string(),
38 time: 0.0,
39 playing: true,
40 yaw_deg: 0.0,
41 pitch_deg: 0.0,
42 distance: 3.4,
43 }
44 }
45}
46
47impl PreviewControls {
48 pub fn drag(&mut self, dx_px: f32, dy_px: f32) {
49 self.yaw_deg = wrap_deg(self.yaw_deg - dx_px * 0.45);
50 self.pitch_deg = (self.pitch_deg + dy_px * 0.35).clamp(-80.0, 80.0);
51 }
52
53 pub fn zoom(&mut self, scroll_y: f32) {
54 let factor = (-scroll_y * 0.0015).exp();
55 self.distance = (self.distance * factor).clamp(0.8, 12.0);
56 }
57
58 pub fn snap_yaw_index(&mut self, index: i32) {
59 let wrapped = index.rem_euclid(PAPERDOLL_YAW_COUNT as i32) as f32;
60 self.yaw_deg = wrapped * 30.0;
61 }
62
63 pub fn snap_pitch_row(&mut self, row: i32) {
64 let row = row.clamp(0, 2) as usize;
65 self.pitch_deg = PAPERDOLL_PITCHES_DEG[row];
66 }
67
68 pub fn advance(&mut self, dt: f32, duration: f32) {
69 if self.playing && duration > 0.0 {
70 self.time = wrap_time(self.time + dt, duration);
71 }
72 }
73
74 pub fn select_clip(&mut self, name: &str) {
75 if self.clip_name != name {
76 self.clip_name = name.to_string();
77 self.time = 0.0;
78 }
79 }
80}
81
82#[derive(Debug, Clone)]
83pub struct PreviewCharacter {
84 pub nodes: Vec<SceneNode>,
85 pub joint_nodes: Vec<usize>,
86 pub joint_names: Vec<String>,
87 pub inverse_binds: Vec<Mat4>,
88 pub positions: Vec<[f32; 3]>,
89 pub normals: Vec<[f32; 3]>,
90 pub joints: Vec<[u16; 4]>,
91 pub weights: Vec<[f32; 4]>,
92 pub indices: Vec<u32>,
93 pub base_color: [f32; 4],
94 pub clips: Vec<PreviewClip>,
95}
96
97#[derive(Debug, Clone)]
98pub struct SceneNode {
99 pub name: String,
100 pub parent: Option<usize>,
101 pub translation: [f32; 3],
102 pub rotation: [f32; 4],
103 pub scale: [f32; 3],
104}
105
106#[derive(Debug, Clone)]
107pub struct PreviewClip {
108 pub name: String,
109 pub duration: f32,
110 pub channels: Vec<AnimChannel>,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum ChannelProperty {
115 Translation,
116 Rotation,
117 Scale,
118}
119
120#[derive(Debug, Clone)]
121pub struct AnimChannel {
122 pub node: usize,
123 pub property: ChannelProperty,
124 pub times: Vec<f32>,
125 pub values: Vec<f32>,
126}
127
128pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
129 let (document, buffers, _images) = gltf::import(path).map_err(|err| err.to_string())?;
130 let mut nodes = Vec::new();
131 let mut parents = vec![None; document.nodes().len()];
132 for node in document.nodes() {
133 for child in node.children() {
134 parents[child.index()] = Some(node.index());
135 }
136 }
137 for node in document.nodes() {
138 let (translation, rotation, scale) = node.transform().decomposed();
139 nodes.push(SceneNode {
140 name: node.name().unwrap_or("").to_string(),
141 parent: parents[node.index()],
142 translation,
143 rotation,
144 scale,
145 });
146 }
147 let skin = document
148 .skins()
149 .next()
150 .ok_or_else(|| "het glTF-bestand heeft geen skeleton".to_string())?;
151 if skin.joints().len() > MAX_JOINTS {
152 return Err(format!(
153 "het skeleton heeft {} joints; de preview stopt bij {MAX_JOINTS}",
154 skin.joints().len()
155 ));
156 }
157 let joint_nodes: Vec<usize> = skin.joints().map(|joint| joint.index()).collect();
158 let joint_names: Vec<String> = joint_nodes
159 .iter()
160 .map(|index| nodes[*index].name.clone())
161 .collect();
162 let reader = skin.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
163 let inverse_binds = reader
164 .read_inverse_bind_matrices()
165 .map(|iter| iter.map(columns_to_mat).collect())
166 .unwrap_or_else(|| vec![identity(); joint_nodes.len()]);
167 if inverse_binds.len() != joint_nodes.len() {
168 return Err("inverse-bindmatrices en joints verschillen".to_string());
169 }
170 let mut positions = Vec::new();
171 let mut normals = Vec::new();
172 let mut joints = Vec::new();
173 let mut weights = Vec::new();
174 let mut indices = Vec::new();
175 let mut base_color = [0.7, 0.7, 0.75, 1.0];
176 if let Some(mesh) = document.meshes().next() {
177 for primitive in mesh.primitives() {
178 let reader = primitive.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
179 let prim_positions: Vec<[f32; 3]> = reader
180 .read_positions()
181 .map(|iter| iter.collect())
182 .unwrap_or_default();
183 let prim_normals: Vec<[f32; 3]> = reader
184 .read_normals()
185 .map(|iter| iter.collect())
186 .unwrap_or_else(|| vec![[0.0, 1.0, 0.0]; prim_positions.len()]);
187 let prim_joints: Vec<[u16; 4]> = reader
188 .read_joints(0)
189 .map(|iter| iter.into_u16().collect())
190 .unwrap_or_else(|| vec![[0, 0, 0, 0]; prim_positions.len()]);
191 let prim_weights: Vec<[f32; 4]> = reader
192 .read_weights(0)
193 .map(|iter| iter.into_f32().collect())
194 .unwrap_or_else(|| vec![[1.0, 0.0, 0.0, 0.0]; prim_positions.len()]);
195 let base = positions.len() as u32;
196 if let Some(read_indices) = reader.read_indices() {
197 indices.extend(read_indices.into_u32().map(|index| index + base));
198 } else {
199 indices.extend(base..base + prim_positions.len() as u32);
200 }
201 positions.extend(prim_positions);
202 normals.extend(prim_normals);
203 joints.extend(prim_joints);
204 weights.extend(prim_weights);
205 base_color = primitive
206 .material()
207 .pbr_metallic_roughness()
208 .base_color_factor();
209 }
210 }
211 if positions.is_empty() || indices.len() < 3 {
212 return Err("het glTF-bestand heeft geen tekenbare driehoeken".to_string());
213 }
214 let mut clips = Vec::new();
215 for animation in document.animations() {
216 let mut channels = Vec::new();
217 let mut duration = 0.0_f32;
218 for channel in animation.channels() {
219 let reader = channel.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
220 let times: Vec<f32> = reader
221 .read_inputs()
222 .map(|iter| iter.collect())
223 .unwrap_or_default();
224 if let Some(last) = times.last() {
225 duration = duration.max(*last);
226 }
227 let Some(outputs) = reader.read_outputs() else {
228 continue;
229 };
230 let (property, values) = match outputs {
231 gltf::animation::util::ReadOutputs::Translations(values) => {
232 (ChannelProperty::Translation, values.flatten().collect())
233 }
234 gltf::animation::util::ReadOutputs::Scales(values) => {
235 (ChannelProperty::Scale, values.flatten().collect())
236 }
237 gltf::animation::util::ReadOutputs::Rotations(values) => (
238 ChannelProperty::Rotation,
239 values.into_f32().flatten().collect(),
240 ),
241 gltf::animation::util::ReadOutputs::MorphTargetWeights(_) => continue,
242 };
243 channels.push(AnimChannel {
244 node: channel.target().node().index(),
245 property,
246 times,
247 values,
248 });
249 }
250 clips.push(PreviewClip {
251 name: animation.name().unwrap_or("naamloos").to_string(),
252 duration,
253 channels,
254 });
255 }
256 Ok(PreviewCharacter {
257 nodes,
258 joint_nodes,
259 joint_names,
260 inverse_binds,
261 positions,
262 normals,
263 joints,
264 weights,
265 indices,
266 base_color,
267 clips,
268 })
269}
270
271pub fn sample_skin_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {
272 let locals = sample_local_matrices(character, clip_name, time);
273 let worlds = world_matrices(&character.nodes, &locals);
274 character
275 .joint_nodes
276 .iter()
277 .enumerate()
278 .map(|(joint, node)| mul(worlds[*node], character.inverse_binds[joint]))
279 .collect()
280}
281
282/// Stable hierarchy path includes node indices to disambiguate duplicate names.
283pub fn joint_paths(character: &PreviewCharacter) -> Vec<String> {
284 character
285 .joint_nodes
286 .iter()
287 .map(|&node| {
288 let mut names = Vec::new();
289 let mut cursor = Some(node);
290 while let Some(index) = cursor {
291 let item = &character.nodes[index];
292 names.push(format!("{}[{}]", item.name, index));
293 cursor = item.parent;
294 if names.len() > character.nodes.len() {
295 break;
296 }
297 }
298 names.reverse();
299 names.join("/")
300 })
301 .collect()
302}
303
304pub fn animated_joint_world(
305 character: &PreviewCharacter,
306 clip: &str,
307 time: f32,
308 joint: usize,
309) -> Option<Mat4> {
310 let locals = sample_local_matrices(character, clip, time);
311 let worlds = world_matrices(&character.nodes, &locals);
312 character
313 .joint_nodes
314 .get(joint)
315 .and_then(|node| worlds.get(*node))
316 .copied()
317}
318
319pub fn joint_world_translation(
320 character: &PreviewCharacter,
321 clip_name: &str,
322 time: f32,
323 joint_name: &str,
324) -> Option<[f32; 3]> {
325 let joint = character
326 .joint_names
327 .iter()
328 .position(|name| name == joint_name)?;
329 let node = character.joint_nodes[joint];
330 let locals = sample_local_matrices(character, clip_name, time);
331 let worlds = world_matrices(&character.nodes, &locals);
332 Some([worlds[node][12], worlds[node][13], worlds[node][14]])
333}
334
335pub fn clip_duration(character: &PreviewCharacter, clip_name: &str) -> f32 {
336 character
337 .clips
338 .iter()
339 .find(|clip| {
340 clip.name == clip_name || clip.name.to_lowercase().contains(&clip_name.to_lowercase())
341 })
342 .map(|clip| clip.duration)
343 .unwrap_or(0.0)
344}
345
346pub fn eye_offset(yaw_deg: f32, pitch_deg: f32, distance: f32) -> [f32; 3] {
347 let yaw = yaw_deg.to_radians();
348 let pitch = pitch_deg.to_radians();
349 [
350 -distance * pitch.cos() * yaw.sin(),
351 distance * pitch.sin(),
352 -distance * pitch.cos() * yaw.cos(),
353 ]
354}
355
356pub fn paperdoll_yaws_deg() -> [f32; PAPERDOLL_YAW_COUNT] {
357 let mut yaws = [0.0; PAPERDOLL_YAW_COUNT];
358 for (index, yaw) in yaws.iter_mut().enumerate() {
359 *yaw = index as f32 * 30.0;
360 }
361 yaws
362}
363
364fn sample_local_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {
365 let mut translation: Vec<[f32; 3]> = character
366 .nodes
367 .iter()
368 .map(|node| node.translation)
369 .collect();
370 let mut rotation: Vec<[f32; 4]> = character.nodes.iter().map(|node| node.rotation).collect();
371 let mut scale: Vec<[f32; 3]> = character.nodes.iter().map(|node| node.scale).collect();
372 if let Some(clip) = character.clips.iter().find(|clip| {
373 clip.name == clip_name || clip.name.to_lowercase().contains(&clip_name.to_lowercase())
374 }) {
375 let time = wrap_time(time, clip.duration);
376 for channel in &clip.channels {
377 if channel.node >= character.nodes.len() || channel.times.is_empty() {
378 continue;
379 }
380 let (left, right, blend) = key_blend(&channel.times, time);
381 match channel.property {
382 ChannelProperty::Translation => {
383 translation[channel.node] = lerp3(
384 key3(&channel.values, left),
385 key3(&channel.values, right),
386 blend,
387 );
388 }
389 ChannelProperty::Scale => {
390 scale[channel.node] = lerp3(
391 key3(&channel.values, left),
392 key3(&channel.values, right),
393 blend,
394 );
395 }
396 ChannelProperty::Rotation => {
397 rotation[channel.node] = slerp(
398 key4(&channel.values, left),
399 key4(&channel.values, right),
400 blend,
401 );
402 }
403 }
404 }
405 }
406 translation
407 .into_iter()
408 .zip(rotation)
409 .zip(scale)
410 .map(|((translation, rotation), scale)| trs(translation, rotation, scale))
411 .collect()
412}
413
414fn world_matrices(nodes: &[SceneNode], locals: &[Mat4]) -> Vec<Mat4> {
415 fn world(
416 index: usize,
417 nodes: &[SceneNode],
418 locals: &[Mat4],
419 worlds: &mut [Option<Mat4>],
420 depth: usize,
421 ) -> Mat4 {
422 if let Some(matrix) = worlds[index] {
423 return matrix;
424 }
425 if depth > nodes.len() {
426 return locals[index];
427 }
428 let matrix = match nodes[index].parent {
429 Some(parent) => mul(
430 world(parent, nodes, locals, worlds, depth + 1),
431 locals[index],
432 ),
433 None => locals[index],
434 };
435 worlds[index] = Some(matrix);
436 matrix
437 }
438 let mut cached = vec![None; nodes.len()];
439 (0..nodes.len())
440 .map(|index| world(index, nodes, locals, &mut cached, 0))
441 .collect()
442}
443
444fn key_blend(times: &[f32], time: f32) -> (usize, usize, f32) {
445 if times.len() == 1 || time <= times[0] {
446 return (0, 0, 0.0);
447 }
448 if time >= times[times.len() - 1] {
449 let last = times.len() - 1;
450 return (last, last, 0.0);
451 }
452 let mut index = 0;
453 while index + 1 < times.len() && times[index + 1] < time {
454 index += 1;
455 }
456 let span = times[index + 1] - times[index];
457 let blend = if span.abs() < 1e-6 {
458 0.0
459 } else {
460 (time - times[index]) / span
461 };
462 (index, index + 1, blend)
463}
464
465fn key3(values: &[f32], index: usize) -> [f32; 3] {
466 let start = index * 3;
467 [
468 values.get(start).copied().unwrap_or(0.0),
469 values.get(start + 1).copied().unwrap_or(0.0),
470 values.get(start + 2).copied().unwrap_or(0.0),
471 ]
472}
473
474fn key4(values: &[f32], index: usize) -> [f32; 4] {
475 let start = index * 4;
476 [
477 values.get(start).copied().unwrap_or(0.0),
478 values.get(start + 1).copied().unwrap_or(0.0),
479 values.get(start + 2).copied().unwrap_or(0.0),
480 values.get(start + 3).copied().unwrap_or(1.0),
481 ]
482}
483
484fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
485 [
486 a[0] + (b[0] - a[0]) * t,
487 a[1] + (b[1] - a[1]) * t,
488 a[2] + (b[2] - a[2]) * t,
489 ]
490}
491
492fn slerp(a: [f32; 4], mut b: [f32; 4], t: f32) -> [f32; 4] {
493 let mut dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
494 if dot < 0.0 {
495 dot = -dot;
496 b = [-b[0], -b[1], -b[2], -b[3]];
497 }
498 if dot > 0.9995 {
499 return normalize4([
500 a[0] + (b[0] - a[0]) * t,
501 a[1] + (b[1] - a[1]) * t,
502 a[2] + (b[2] - a[2]) * t,
503 a[3] + (b[3] - a[3]) * t,
504 ]);
505 }
506 let theta = dot.clamp(-1.0, 1.0).acos();
507 let sin_theta = theta.sin();
508 let w1 = ((1.0 - t) * theta).sin() / sin_theta;
509 let w2 = (t * theta).sin() / sin_theta;
510 [
511 a[0] * w1 + b[0] * w2,
512 a[1] * w1 + b[1] * w2,
513 a[2] * w1 + b[2] * w2,
514 a[3] * w1 + b[3] * w2,
515 ]
516}
517
518fn normalize4(value: [f32; 4]) -> [f32; 4] {
519 let length =
520 (value[0] * value[0] + value[1] * value[1] + value[2] * value[2] + value[3] * value[3])
521 .sqrt();
522 if length < 1e-8 {
523 [0.0, 0.0, 0.0, 1.0]
524 } else {
525 [
526 value[0] / length,
527 value[1] / length,
528 value[2] / length,
529 value[3] / length,
530 ]
531 }
532}
533
534fn trs(translation: [f32; 3], rotation: [f32; 4], scale: [f32; 3]) -> Mat4 {
535 let mut matrix = quat_to_mat(normalize4(rotation));
536 matrix[0] *= scale[0];
537 matrix[1] *= scale[0];
538 matrix[2] *= scale[0];
539 matrix[4] *= scale[1];
540 matrix[5] *= scale[1];
541 matrix[6] *= scale[1];
542 matrix[8] *= scale[2];
543 matrix[9] *= scale[2];
544 matrix[10] *= scale[2];
545 matrix[12] = translation[0];
546 matrix[13] = translation[1];
547 matrix[14] = translation[2];
548 matrix
549}
550
551fn quat_to_mat(q: [f32; 4]) -> Mat4 {
552 let (x, y, z, w) = (q[0], q[1], q[2], q[3]);
553 let xx = x * x;
554 let yy = y * y;
555 let zz = z * z;
556 let xy = x * y;
557 let xz = x * z;
558 let yz = y * z;
559 let wx = w * x;
560 let wy = w * y;
561 let wz = w * z;
562 [
563 1.0 - 2.0 * (yy + zz),
564 2.0 * (xy + wz),
565 2.0 * (xz - wy),
566 0.0,
567 2.0 * (xy - wz),
568 1.0 - 2.0 * (xx + zz),
569 2.0 * (yz + wx),
570 0.0,
571 2.0 * (xz + wy),
572 2.0 * (yz - wx),
573 1.0 - 2.0 * (xx + yy),
574 0.0,
575 0.0,
576 0.0,
577 0.0,
578 1.0,
579 ]
580}
581
582pub fn identity() -> Mat4 {
583 [
584 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,
585 ]
586}
587
588pub fn mul(left: Mat4, right: Mat4) -> Mat4 {
589 let mut out = [0.0; 16];
590 for column in 0..4 {
591 for row in 0..4 {
592 out[column * 4 + row] = left[row] * right[column * 4]
593 + left[4 + row] * right[column * 4 + 1]
594 + left[8 + row] * right[column * 4 + 2]
595 + left[12 + row] * right[column * 4 + 3];
596 }
597 }
598 out
599}
600
601fn columns_to_mat(columns: [[f32; 4]; 4]) -> Mat4 {
602 let mut matrix = [0.0; 16];
603 for column in 0..4 {
604 for row in 0..4 {
605 matrix[column * 4 + row] = columns[column][row];
606 }
607 }
608 matrix
609}
610
611pub fn wrap_time(time: f32, duration: f32) -> f32 {
612 if duration <= 1e-5 {
613 return 0.0;
614 }
615 let mut wrapped = time % duration;
616 if wrapped < 0.0 {
617 wrapped += duration;
618 }
619 wrapped
620}
621
622fn wrap_deg(mut degrees: f32) -> f32 {
623 degrees %= 360.0;
624 if degrees < 0.0 {
625 degrees += 360.0;
626 }
627 degrees
628}
629
630pub fn view_matrix(eye: [f32; 3], target: [f32; 3]) -> Mat4 {
631 let forward = normalize3([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]);
632 let up = [0.0, 1.0, 0.0];
633 let mut side = normalize3(cross(forward, up));
634 if side[0].abs() + side[1].abs() + side[2].abs() < 1e-4 {
635 side = [1.0, 0.0, 0.0];
636 }
637 let up = cross(side, forward);
638 [
639 side[0],
640 up[0],
641 -forward[0],
642 0.0,
643 side[1],
644 up[1],
645 -forward[1],
646 0.0,
647 side[2],
648 up[2],
649 -forward[2],
650 0.0,
651 -dot(side, eye),
652 -dot(up, eye),
653 dot(forward, eye),
654 1.0,
655 ]
656}
657
658pub fn perspective(fov_y_deg: f32, aspect: f32, near: f32, far: f32) -> Mat4 {
659 let f = 1.0 / (fov_y_deg.to_radians() * 0.5).tan();
660 let aspect = if aspect.abs() < 1e-4 { 1.0 } else { aspect };
661 let nf = 1.0 / (near - far);
662 [
663 f / aspect,
664 0.0,
665 0.0,
666 0.0,
667 0.0,
668 f,
669 0.0,
670 0.0,
671 0.0,
672 0.0,
673 (far + near) * nf,
674 -1.0,
675 0.0,
676 0.0,
677 2.0 * far * near * nf,
678 0.0,
679 ]
680}
681
682fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
683 [
684 a[1] * b[2] - a[2] * b[1],
685 a[2] * b[0] - a[0] * b[2],
686 a[0] * b[1] - a[1] * b[0],
687 ]
688}
689
690fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
691 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
692}
693
694fn normalize3(value: [f32; 3]) -> [f32; 3] {
695 let length = dot(value, value).sqrt();
696 if length < 1e-8 {
697 [0.0, 0.0, 1.0]
698 } else {
699 [value[0] / length, value[1] / length, value[2] / length]
700 }
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706
707 fn male() -> PreviewCharacter {
708 load_gltf_file(
709 &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),
710 )
711 .unwrap()
712 }
713
714 fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {
715 let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
716 dot(d, d).sqrt()
717 }
718
719 #[test]
720 fn walk_and_attack_move_the_matching_bones() {
721 let character = male();
722 assert!(character.clips.iter().any(|clip| clip.name == "walk"));
723 assert!(character.clips.iter().any(|clip| clip.name == "attack"));
724 assert_eq!(character.joint_names.len(), 19);
725 assert!(character.indices.len() >= 3);
726 assert!(character.positions.len() >= 19 * 8);
727 let leg_a = joint_world_translation(&character, "walk", 0.0, "LeftLowerLeg").unwrap();
728 let leg_b = joint_world_translation(&character, "walk", 0.25, "LeftLowerLeg").unwrap();
729 assert!(
730 distance(leg_a, leg_b) > 0.05,
731 "walk should move the lower leg, delta {}",
732 distance(leg_a, leg_b)
733 );
734 let arm_a = joint_world_translation(&character, "attack", 0.0, "RightHand").unwrap();
735 let arm_b = joint_world_translation(&character, "attack", 0.35, "RightHand").unwrap();
736 assert!(
737 distance(arm_a, arm_b) > 0.05,
738 "attack should move the right hand, delta {}",
739 distance(arm_a, arm_b)
740 );
741 let matrices = sample_skin_matrices(&character, "walk", 0.2);
742 assert_eq!(matrices.len(), 19);
743 assert!(matrices.iter().flatten().all(|value| value.is_finite()));
744 }
745
746 #[test]
747 fn female_fixture_has_the_same_clip_names() {
748 let character = load_gltf_file(
749 &Path::new(env!("CARGO_MANIFEST_DIR"))
750 .join("tests/fixtures/bodies/test-body-female.glb"),
751 )
752 .unwrap();
753 assert!(character.clips.iter().any(|clip| clip.name == "walk"));
754 assert!(character.clips.iter().any(|clip| clip.name == "attack"));
755 let a = joint_world_translation(&character, "walk", 0.0, "RightLowerLeg").unwrap();
756 let b = joint_world_translation(&character, "walk", 0.5, "RightLowerLeg").unwrap();
757 assert!(distance(a, b) > 0.05);
758 }
759
760 #[test]
761 fn yaw_zero_is_in_front_and_the_twelve_yaws_differ() {
762 let front = eye_offset(0.0, 0.0, 4.0);
763 assert!(front[2] < -3.5, "yaw 0 camera belongs on -Z, got {front:?}");
764 assert!(front[0].abs() < 0.05);
765 let mut seen = Vec::new();
766 for yaw in paperdoll_yaws_deg() {
767 let eye = eye_offset(yaw, 0.0, 4.0);
768 assert!(seen
769 .iter()
770 .all(|previous: &[f32; 3]| distance(*previous, eye) > 0.5));
771 seen.push(eye);
772 }
773 assert_eq!(seen.len(), 12);
774 let low = eye_offset(0.0, -30.0, 4.0);
775 let high = eye_offset(0.0, 30.0, 4.0);
776 assert!(low[1] < 0.0 && high[1] > 0.0);
777 assert_eq!(PAPERDOLL_PITCHES_DEG.len(), 3);
778 }
779
780 #[test]
781 fn controls_snap_wrap_and_keep_a_clip_change() {
782 let mut controls = PreviewControls::default();
783 controls.drag(-100.0, 40.0);
784 assert!(controls.yaw_deg > 40.0);
785 assert!(controls.pitch_deg > 10.0);
786 controls.zoom(400.0);
787 assert!(controls.distance < 3.4);
788 controls.snap_yaw_index(-1);
789 assert!((controls.yaw_deg - 330.0).abs() < 0.01);
790 controls.snap_pitch_row(99);
791 assert!((controls.pitch_deg - 30.0).abs() < 0.01);
792 controls.snap_pitch_row(-4);
793 assert!((controls.pitch_deg + 30.0).abs() < 0.01);
794 controls.time = 0.4;
795 controls.select_clip("attack");
796 assert_eq!(controls.clip_name, "attack");
797 assert_eq!(controls.time, 0.0);
798 controls.time = 1.2;
799 controls.advance(0.0, 1.0);
800 controls.playing = true;
801 controls.advance(0.3, 1.0);
802 assert!((controls.time - 0.5).abs() < 0.02 || (wrap_time(1.5, 1.0) - 0.5).abs() < 0.02);
803 }
804}