BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / equipment_rig.rs

equipment_rig.rs

equipment_rig.rs 24.4 KiB Download Raw
1//! Equipment with its own skeleton (Tim t1027u).
2//!
3//! A skinned equipment model is stored rigid per bone: every vertex belongs to
4//! its dominant skin bone and is kept in that bone's rest space. Per sex, the
5//! equipment bones are linked to character bones; each link has its own
6//! position and rotation relative to the character bone. Unlinked bones follow
7//! their nearest linked ancestor (or the first link) rigidly.
8
9use std::path::Path;
10
11use serde::{Deserialize, Serialize};
12
13use crate::equipment::{local_matrix, Attachment, EmbeddedAsset, Mesh, Preset, RigSignature};
14use crate::preview::{animated_joint_world, joint_paths, mul, Mat4, PreviewCharacter};
15
16/// Error of [`import_skinned`] for a model without a skin.
17pub const NOT_SKINNED: &str =
18 "het model heeft geen skelet; kies OBJ of FBX voor een rigide onderdeel";
19
20const MAX_BONES: usize = 256;
21const MAX_SKINNED_MESHES: usize = 1024;
22
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25pub struct EquipBone {
26 pub name: String,
27 pub parent: Option<usize>,
28 /// Rest-pose world matrix in the equipment model's space.
29 pub rest_world: Mat4,
30}
31
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct Skeleton {
35 pub bones: Vec<EquipBone>,
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct BoneLink {
41 /// Equipment bone index.
42 pub bone: usize,
43 /// Character joint path (as in [`joint_paths`]).
44 pub target: String,
45 pub translation: [f32; 3],
46 pub rotation_deg: [f32; 3],
47}
48
49pub fn links_empty(links: &[Vec<BoneLink>; 2]) -> bool {
50 links.iter().all(Vec::is_empty)
51}
52
53/// Structural checks for the skinned fields of one part.
54pub(crate) fn validate_part(
55 part: &Attachment,
56 rigs: &[Option<RigSignature>; 2],
57) -> Result<(), String> {
58 let Some(skeleton) = &part.skeleton else {
59 if !links_empty(&part.bone_links) || part.meshes.iter().any(|m| m.bone.is_some()) {
60 return Err("botkoppeling zonder skelet".into());
61 }
62 return Ok(());
63 };
64 let n = skeleton.bones.len();
65 if n == 0 || n > MAX_BONES || part.meshes.len() > MAX_SKINNED_MESHES {
66 return Err("ongeldig equipmentskelet".into());
67 }
68 for (i, bone) in skeleton.bones.iter().enumerate() {
69 if bone.name.len() > 256
70 || bone.rest_world.iter().any(|x| !x.is_finite())
71 || bone.parent.is_some_and(|p| p >= n || p == i)
72 {
73 return Err("ongeldig equipmentskelet".into());
74 }
75 // Acyclic: a parent walk ends within n steps.
76 let mut cursor = bone.parent;
77 let mut steps = 0;
78 while let Some(p) = cursor {
79 steps += 1;
80 if steps > n {
81 return Err("equipmentskelet bevat een lus".into());
82 }
83 cursor = skeleton.bones[p].parent;
84 }
85 }
86 if part.meshes.iter().any(|m| m.bone.is_none_or(|b| b >= n)) {
87 return Err("mesh verwijst naar onbekend equipmentbot".into());
88 }
89 for (sex, links) in part.bone_links.iter().enumerate() {
90 let mut seen = std::collections::BTreeSet::new();
91 for link in links {
92 if link.bone >= n
93 || !seen.insert(link.bone)
94 || link.target.len() > 4096
95 || link
96 .translation
97 .iter()
98 .chain(link.rotation_deg.iter())
99 .any(|x| !x.is_finite() || x.abs() > 100_000.0)
100 {
101 return Err("ongeldige botkoppeling".into());
102 }
103 if !rigs[sex]
104 .as_ref()
105 .is_some_and(|rig| rig.paths.contains(&link.target))
106 {
107 return Err("botkoppeling verwijst naar onbekend bot".into());
108 }
109 }
110 }
111 Ok(())
112}
113
114/// Model matrix per equipment bone for the current pose.
115pub fn bone_models(
116 skeleton: &Skeleton,
117 links: &[BoneLink],
118 paths: &[String],
119 world_of: impl Fn(usize) -> Option<Mat4>,
120) -> Result<Vec<Mat4>, String> {
121 if links.is_empty() {
122 return Err("koppel minstens één equipmentbot aan een karakterbot".into());
123 }
124 let mut anchors = Vec::with_capacity(links.len());
125 for link in links {
126 let joint = paths
127 .iter()
128 .position(|p| p == &link.target)
129 .ok_or("het gekoppelde bot ontbreekt")?;
130 let world = world_of(joint).ok_or("het gekoppelde bot ontbreekt")?;
131 let rest = skeleton
132 .bones
133 .get(link.bone)
134 .ok_or("ongeldige botkoppeling")?
135 .rest_world;
136 anchors.push(mul(
137 mul(world, local_matrix(link.translation, link.rotation_deg)),
138 inverse(rest),
139 ));
140 }
141 Ok((0..skeleton.bones.len())
142 .map(|bone| {
143 let anchor = nearest_link(skeleton, links, bone).unwrap_or(0);
144 mul(anchors[anchor], skeleton.bones[bone].rest_world)
145 })
146 .collect())
147}
148
149/// Index into `links` of the bone itself or its nearest linked ancestor.
150pub fn nearest_link(skeleton: &Skeleton, links: &[BoneLink], bone: usize) -> Option<usize> {
151 let mut cursor = Some(bone);
152 let mut steps = 0;
153 while let Some(b) = cursor {
154 if let Some(i) = links.iter().position(|l| l.bone == b) {
155 return Some(i);
156 }
157 steps += 1;
158 if steps > skeleton.bones.len() {
159 return None;
160 }
161 cursor = skeleton.bones.get(b)?.parent;
162 }
163 None
164}
165
166/// Import a skinned glTF/GLB/FBX as one skinned part.
167pub fn import_skinned(preset: &mut Preset, source: &Path) -> Result<usize, String> {
168 let model = crate::preview::load_body_file(source)?;
169 if model.is_static() || model.joint_nodes.is_empty() {
170 return Err(NOT_SKINNED.into());
171 }
172 let label = source
173 .file_name()
174 .and_then(|n| n.to_str())
175 .ok_or("ongeldige bestandsnaam")?
176 .to_string();
177 let part = skinned_part(&model)?;
178 let mut candidate = preset.clone();
179 let (meshes, assets) = part;
180 let base = candidate.assets.len();
181 let mut source_assets = Vec::new();
182 for (i, asset) in assets.into_iter().enumerate() {
183 let mut path = format!("{}/texture{i}.png", sanitize(&label));
184 while candidate.assets.iter().any(|a| a.path == path) {
185 path = format!("_{path}");
186 }
187 source_assets.push(base + i);
188 candidate.assets.push(EmbeddedAsset { path, bytes: asset });
189 }
190 let meshes = meshes
191 .into_iter()
192 .map(|mut m| {
193 m.albedo = m.albedo.map(|i| base + i);
194 m
195 })
196 .collect();
197 candidate.attachments.push(Attachment {
198 label,
199 source_assets,
200 bones: [None, None],
201 translation: [0.; 3],
202 rotation_deg: [0.; 3],
203 meshes,
204 shiny: None,
205 specular: 0.25,
206 skeleton: Some(skeleton_of(&model)),
207 bone_links: [Vec::new(), Vec::new()],
208 });
209 candidate.validate()?;
210 *preset = candidate;
211 Ok(preset.attachments.len() - 1)
212}
213
214fn sanitize(label: &str) -> String {
215 label
216 .chars()
217 .map(|c| {
218 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
219 c
220 } else {
221 '_'
222 }
223 })
224 .collect()
225}
226
227pub fn skeleton_of(model: &PreviewCharacter) -> Skeleton {
228 let bones = (0..model.joint_nodes.len())
229 .map(|j| {
230 let mut cursor = model.nodes[model.joint_nodes[j]].parent;
231 let mut parent = None;
232 let mut steps = 0;
233 while let Some(node) = cursor {
234 if let Some(p) = model.joint_nodes.iter().position(|n| *n == node) {
235 parent = Some(p);
236 break;
237 }
238 steps += 1;
239 if steps > model.nodes.len() {
240 break;
241 }
242 cursor = model.nodes[node].parent;
243 }
244 EquipBone {
245 name: model.joint_names.get(j).cloned().unwrap_or_default(),
246 parent,
247 rest_world: rest_world(model, j),
248 }
249 })
250 .collect();
251 Skeleton { bones }
252}
253
254fn rest_world(model: &PreviewCharacter, joint: usize) -> Mat4 {
255 animated_joint_world(model, "", 0.0, joint).unwrap_or_else(crate::preview::identity)
256}
257
258type PartData = (Vec<Mesh>, Vec<Vec<u8>>);
259
260/// Rest-pose skinning, split per surface and dominant bone, vertices in bone space.
261fn skinned_part(model: &PreviewCharacter) -> Result<PartData, String> {
262 let n = model.positions.len();
263 if n == 0 || n > crate::equipment::MAX_VERTICES {
264 return Err("het equipmentmodel heeft geen of te veel vertices".into());
265 }
266 if model.joint_nodes.len() > MAX_BONES {
267 return Err("te veel botten in het equipmentmodel".into());
268 }
269 let rests: Vec<Mat4> = (0..model.joint_nodes.len())
270 .map(|j| rest_world(model, j))
271 .collect();
272 let to_bone: Vec<Mat4> = rests.iter().map(|m| inverse(*m)).collect();
273 let skins: Vec<Mat4> = rests
274 .iter()
275 .zip(&model.inverse_binds)
276 .map(|(r, ib)| mul(*r, *ib))
277 .collect();
278 let mut rest_pos = Vec::with_capacity(n);
279 let mut rest_nrm = Vec::with_capacity(n);
280 let mut dominant = Vec::with_capacity(n);
281 for v in 0..n {
282 let joints = model.joints.get(v).copied().unwrap_or([0; 4]);
283 let weights = model.weights.get(v).copied().unwrap_or([1., 0., 0., 0.]);
284 let mut m = [0f32; 16];
285 let mut total = 0.;
286 for (j, w) in joints.iter().zip(weights) {
287 let skin = skins.get(*j as usize).ok_or("ongeldige skinindex")?;
288 for k in 0..16 {
289 m[k] += skin[k] * w;
290 }
291 total += w;
292 }
293 if total <= 1e-6 {
294 m = skins[0];
295 }
296 rest_pos.push(apply(&m, model.positions[v], 1.));
297 rest_nrm.push(apply(
298 &m,
299 model.normals.get(v).copied().unwrap_or([0., 1., 0.]),
300 0.,
301 ));
302 let best = (0..4)
303 .max_by(|a, b| weights[*a].total_cmp(&weights[*b]))
304 .unwrap_or(0);
305 dominant.push(joints[best] as usize);
306 }
307 // Textures: PNG-encode each distinct decoded albedo once.
308 let mut assets: Vec<Vec<u8>> = Vec::new();
309 let mut hashes: Vec<u64> = Vec::new();
310 let mut meshes = Vec::new();
311 for surface in &model.surfaces {
312 let albedo = match &surface.albedo {
313 Some(texture) => Some(
314 match hashes.iter().position(|h| *h == texture.content_hash) {
315 Some(i) => i,
316 None => {
317 assets.push(encode_png(texture)?);
318 hashes.push(texture.content_hash);
319 assets.len() - 1
320 }
321 },
322 ),
323 None => None,
324 };
325 let indices = model
326 .indices
327 .get(surface.first..surface.first + surface.count)
328 .ok_or("ongeldige surface")?;
329 let mut groups: std::collections::BTreeMap<usize, Vec<u32>> = Default::default();
330 for t in indices.chunks_exact(3) {
331 // The triangle goes to the most common dominant bone of its corners.
332 let d = [0, 1, 2].map(|k| dominant[t[k] as usize]);
333 let bone = if d[1] == d[2] { d[1] } else { d[0] };
334 groups.entry(bone).or_default().extend_from_slice(t);
335 }
336 for (bone, tri) in groups {
337 let mut remap = std::collections::HashMap::new();
338 let mut mesh = Mesh {
339 positions: Vec::new(),
340 normals: Vec::new(),
341 uvs: Vec::new(),
342 indices: Vec::new(),
343 color: surface.color,
344 albedo,
345 bone: Some(bone),
346 };
347 for v in tri {
348 let next = mesh.positions.len() as u32;
349 let index = *remap.entry(v).or_insert_with(|| {
350 let v = v as usize;
351 mesh.positions.push(apply(&to_bone[bone], rest_pos[v], 1.));
352 let nv = apply(&to_bone[bone], rest_nrm[v], 0.);
353 let l = (nv[0] * nv[0] + nv[1] * nv[1] + nv[2] * nv[2])
354 .sqrt()
355 .max(1e-9);
356 mesh.normals.push(nv.map(|x| x / l));
357 // Equipment meshes use the OBJ convention (origin bottom-left).
358 let uv = model.uvs.get(v).copied().unwrap_or([0., 0.]);
359 mesh.uvs.push([uv[0], 1. - uv[1]]);
360 next
361 });
362 mesh.indices.push(index);
363 }
364 meshes.push(mesh);
365 }
366 }
367 if meshes.is_empty() {
368 return Err("het equipmentmodel heeft geen tekenbare meshes".into());
369 }
370 Ok((meshes, assets))
371}
372
373fn encode_png(texture: &crate::equipment::Texture) -> Result<Vec<u8>, String> {
374 let mut out = Vec::new();
375 {
376 let mut encoder = png::Encoder::new(&mut out, texture.width, texture.height);
377 encoder.set_color(png::ColorType::Rgba);
378 encoder.set_depth(png::BitDepth::Eight);
379 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
380 writer
381 .write_image_data(&texture.rgba)
382 .map_err(|e| e.to_string())?;
383 }
384 Ok(out)
385}
386
387fn apply(m: &Mat4, p: [f32; 3], w: f32) -> [f32; 3] {
388 [0, 1, 2].map(|i| m[i] * p[0] + m[4 + i] * p[1] + m[8 + i] * p[2] + m[12 + i] * w)
389}
390
391/// General 4x4 inverse (column-major), Gauss-Jordan in f64.
392pub fn inverse(m: Mat4) -> Mat4 {
393 let mut a = [[0f64; 8]; 4];
394 for r in 0..4 {
395 for c in 0..4 {
396 a[r][c] = m[c * 4 + r] as f64;
397 }
398 a[r][4 + r] = 1.;
399 }
400 for c in 0..4 {
401 let p = (c..4)
402 .max_by(|&x, &y| a[x][c].abs().total_cmp(&a[y][c].abs()))
403 .unwrap_or(c);
404 a.swap(c, p);
405 let d = a[c][c];
406 if d.abs() < 1e-12 {
407 return crate::preview::identity();
408 }
409 for value in a[c].iter_mut() {
410 *value /= d;
411 }
412 for r in 0..4 {
413 if r != c {
414 let f = a[r][c];
415 let pivot = a[c];
416 for (value, p) in a[r].iter_mut().zip(pivot) {
417 *value -= f * p;
418 }
419 }
420 }
421 }
422 let mut out = [0f32; 16];
423 for r in 0..4 {
424 for c in 0..4 {
425 out[c * 4 + r] = a[r][4 + c] as f32;
426 }
427 }
428 out
429}
430
431/// Translation and Rz·Ry·Rx degrees of a rigid matrix (inverse of `local_matrix`).
432pub fn decompose(m: &Mat4) -> ([f32; 3], [f32; 3]) {
433 let col = |c: usize| {
434 let v = [m[c * 4], m[c * 4 + 1], m[c * 4 + 2]];
435 let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12);
436 v.map(|x| x / l)
437 };
438 let (c0, c1, c2) = (col(0), col(1), col(2));
439 let (r00, r10, r20) = (c0[0], c0[1], c0[2]);
440 let (r21, r22) = (c1[2], c2[2]);
441 let y = (-r20).clamp(-1., 1.).asin();
442 let (x, z) = if y.cos().abs() > 1e-5 {
443 (r21.atan2(r22), r10.atan2(r00))
444 } else {
445 (0., (-c1[0]).atan2(c1[1]))
446 };
447 let clean = |v: f32| if v.abs() < 1e-5 { 0. } else { v };
448 (
449 [m[12], m[13], m[14]].map(clean),
450 [x, y, z].map(|a| clean(a.to_degrees())),
451 )
452}
453
454/// Link equipment `bone` of `part` to character `joint` (or unlink with `None`),
455/// with the offset that keeps the bone at its rest placement.
456pub fn set_link(
457 preset: &mut Preset,
458 part: usize,
459 sex: usize,
460 body: &PreviewCharacter,
461 bone: usize,
462 joint: Option<usize>,
463) -> Result<(), String> {
464 if sex > 1 {
465 return Err("ongeldig geslacht".into());
466 }
467 let signature = RigSignature::of(body);
468 let paths = joint_paths(body);
469 let item = preset
470 .attachments
471 .get_mut(part)
472 .ok_or("dit onderdeel bestaat niet")?;
473 let skeleton = item
474 .skeleton
475 .as_ref()
476 .ok_or("dit onderdeel heeft geen skelet")?;
477 let rest = skeleton
478 .bones
479 .get(bone)
480 .ok_or("dit equipmentbot bestaat niet")?
481 .rest_world;
482 let links = &mut item.bone_links[sex];
483 links.retain(|l| l.bone != bone);
484 if let Some(joint) = joint {
485 let target = paths.get(joint).ok_or("dit bot bestaat niet")?.clone();
486 let char_rest = signature.rest_world[joint];
487 let (translation, rotation_deg) = decompose(&mul(inverse(char_rest), rest));
488 links.push(BoneLink {
489 bone,
490 target,
491 translation,
492 rotation_deg,
493 });
494 links.sort_by_key(|l| l.bone);
495 }
496 // Keep the rig signature current; drop links to bones of an older rig.
497 if preset.rigs[sex].as_ref() != Some(&signature) {
498 preset.rigs[sex] = Some(signature);
499 for a in &mut preset.attachments {
500 a.bone_links[sex].retain(|l| paths.contains(&l.target));
501 if a.bones[sex].as_ref().is_some_and(|p| !paths.contains(p)) {
502 a.bones[sex] = None;
503 }
504 }
505 }
506 Ok(())
507}
508
509/// Fill unlinked equipment bones by name; returns how many links were added.
510pub fn auto_link(
511 preset: &mut Preset,
512 part: usize,
513 sex: usize,
514 body: &PreviewCharacter,
515) -> Result<usize, String> {
516 let names: Vec<String> = preset
517 .attachments
518 .get(part)
519 .and_then(|a| a.skeleton.as_ref())
520 .ok_or("dit onderdeel heeft geen skelet")?
521 .bones
522 .iter()
523 .map(|b| b.name.clone())
524 .collect();
525 let mut added = 0;
526 for (bone, name) in names.iter().enumerate() {
527 if preset.attachments[part].bone_links[sex]
528 .iter()
529 .any(|l| l.bone == bone)
530 {
531 continue;
532 }
533 if let Some(joint) = best_match(name, &body.joint_names) {
534 set_link(preset, part, sex, body, bone, Some(joint))?;
535 added += 1;
536 }
537 }
538 Ok(added)
539}
540
541/// Best character bone for an equipment bone name (see design.md t1027u).
542pub fn best_match(name: &str, candidates: &[String]) -> Option<usize> {
543 let mut best: Option<(u8, usize)> = None;
544 for (i, candidate) in candidates.iter().enumerate() {
545 let score = match_score(name, candidate);
546 if score > 0 && best.is_none_or(|(s, _)| score > s) {
547 best = Some((score, i));
548 }
549 }
550 best.map(|(_, i)| i)
551}
552
553/// 3 exact (case-insensitive), 2 equal after normalising, 1 partial, 0 none.
554pub fn match_score(a: &str, b: &str) -> u8 {
555 if a.eq_ignore_ascii_case(b) {
556 return 3;
557 }
558 let (ca, sa) = normalize(a);
559 let (cb, sb) = normalize(b);
560 if ca.is_empty() || cb.is_empty() || sa != sb {
561 return 0;
562 }
563 if ca == cb {
564 return 2;
565 }
566 if ca.len().min(cb.len()) >= 3 && (ca.contains(&cb) || cb.contains(&ca)) {
567 return 1;
568 }
569 0
570}
571
572/// Normalised core name and side tag (`Some('l')`/`Some('r')`).
573pub fn normalize(name: &str) -> (String, Option<char>) {
574 let mut rest = name;
575 if let Some(i) = rest.rfind([':', '|']) {
576 rest = &rest[i + 1..];
577 }
578 let lower = rest.to_ascii_lowercase();
579 for prefix in ["mixamorig", "bip001", "bip01", "def-", "def_", "def."] {
580 if lower.starts_with(prefix) {
581 rest = &rest[prefix.len()..];
582 break;
583 }
584 }
585 // Tokens on separators, camelCase and letter/digit boundaries.
586 let mut tokens: Vec<String> = Vec::new();
587 let mut current = String::new();
588 let mut previous: Option<char> = None;
589 for ch in rest.chars() {
590 if !ch.is_ascii_alphanumeric() {
591 if !current.is_empty() {
592 tokens.push(std::mem::take(&mut current));
593 }
594 previous = None;
595 continue;
596 }
597 if let Some(p) = previous {
598 let boundary = (p.is_ascii_lowercase() && ch.is_ascii_uppercase())
599 || (p.is_ascii_alphabetic() && ch.is_ascii_digit())
600 || (p.is_ascii_digit() && ch.is_ascii_alphabetic());
601 if boundary && !current.is_empty() {
602 tokens.push(std::mem::take(&mut current));
603 }
604 }
605 current.push(ch.to_ascii_lowercase());
606 previous = Some(ch);
607 }
608 if !current.is_empty() {
609 tokens.push(current);
610 }
611 while tokens
612 .last()
613 .is_some_and(|t| t.chars().all(|c| c.is_ascii_digit()))
614 {
615 tokens.pop();
616 }
617 let side_of = |t: &str| match t {
618 "l" | "left" => Some('l'),
619 "r" | "right" => Some('r'),
620 _ => None,
621 };
622 let mut side = None;
623 if tokens.len() > 1 {
624 if let Some(s) = side_of(&tokens[0]) {
625 side = Some(s);
626 tokens.remove(0);
627 } else if let Some(s) = side_of(tokens.last().unwrap()) {
628 side = Some(s);
629 tokens.pop();
630 }
631 }
632 while tokens
633 .last()
634 .is_some_and(|t| t.chars().all(|c| c.is_ascii_digit()))
635 {
636 tokens.pop();
637 }
638 (tokens.concat(), side)
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644
645 #[test]
646 fn names_normalise_prefixes_sides_and_numeric_suffixes() {
647 assert_eq!(normalize("mixamorig:LeftArm"), ("arm".into(), Some('l')));
648 assert_eq!(normalize("Armature|Arm.L"), ("arm".into(), Some('l')));
649 assert_eq!(normalize("hand_r.001"), ("hand".into(), Some('r')));
650 assert_eq!(
651 normalize("Bip01 R UpperArm"),
652 ("upperarm".into(), Some('r'))
653 );
654 assert_eq!(normalize("DEF-spine.003"), ("spine".into(), None));
655 assert_eq!(normalize("Head"), ("head".into(), None));
656 assert_eq!(
657 normalize("RightHandIndex1"),
658 ("handindex".into(), Some('r'))
659 );
660 }
661
662 #[test]
663 fn match_prefers_exact_then_normalised_then_partial_and_respects_sides() {
664 let body: Vec<String> = [
665 "Hips",
666 "Spine",
667 "Spine1",
668 "LeftUpLeg",
669 "RightUpLeg",
670 "LeftHand",
671 "RightHand",
672 "Head",
673 ]
674 .map(String::from)
675 .to_vec();
676 assert_eq!(best_match("Spine1", &body), Some(2)); // exact
677 assert_eq!(best_match("mixamorig:Spine1", &body), Some(1)); // normalised: first "spine"
678 assert_eq!(best_match("hand.R", &body), Some(6));
679 assert_eq!(best_match("Armature|hand_l", &body), Some(5));
680 assert_eq!(best_match("upleg.L", &body), Some(3));
681 assert_eq!(best_match("head_end", &body), Some(7)); // partial
682 assert_eq!(best_match("tail", &body), None);
683 assert_eq!(match_score("LeftHand", "RightHand"), 0);
684 }
685
686 #[test]
687 fn decompose_inverts_local_matrix() {
688 for (t, r) in [
689 ([0.1, -0.37, 2.0], [10.0, 20.0, 30.0]),
690 ([0.0, 0.0, 0.0], [-45.0, 80.0, 170.0]),
691 ([1.0, 2.0, 3.0], [0.0, 0.0, 0.0]),
692 ] {
693 let m = local_matrix(t, r);
694 let (t2, r2) = decompose(&m);
695 let m2 = local_matrix(t2, r2);
696 for k in 0..16 {
697 assert!((m[k] - m2[k]).abs() < 1e-4, "{t:?} {r:?} -> {t2:?} {r2:?}");
698 }
699 }
700 }
701
702 #[test]
703 fn unlinked_bones_follow_nearest_linked_ancestor_then_first_link() {
704 let id = crate::preview::identity();
705 let at = |x: f32| local_matrix([x, 0., 0.], [0.; 3]);
706 let skeleton = Skeleton {
707 bones: vec![
708 EquipBone {
709 name: "root".into(),
710 parent: None,
711 rest_world: id,
712 },
713 EquipBone {
714 name: "a".into(),
715 parent: Some(0),
716 rest_world: at(1.),
717 },
718 EquipBone {
719 name: "b".into(),
720 parent: Some(1),
721 rest_world: at(2.),
722 },
723 EquipBone {
724 name: "c".into(),
725 parent: Some(0),
726 rest_world: at(3.),
727 },
728 ],
729 };
730 let links = vec![BoneLink {
731 bone: 1,
732 target: "j".into(),
733 translation: [0.; 3],
734 rotation_deg: [0.; 3],
735 }];
736 assert_eq!(nearest_link(&skeleton, &links, 2), Some(0));
737 assert_eq!(nearest_link(&skeleton, &links, 3), None);
738 let paths = vec!["j".to_string()];
739 let models = bone_models(&skeleton, &links, &paths, |_| Some(at(10.))).unwrap();
740 // Linked bone sits on the joint; its child keeps its rest offset (+1).
741 assert!((models[1][12] - 10.).abs() < 1e-5);
742 assert!((models[2][12] - 11.).abs() < 1e-5);
743 assert!(bone_models(&skeleton, &[], &paths, |_| Some(id)).is_err());
744 }
745}