BlueGit

golden-age paperdoll-renderer

Merge feature/gltf-multimesh (#12a multi-mesh/multi-skin glTF)

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 7 days ago

Hash
d9cdb536873cefe0b7cc60d0416e0b6f062bc795
Pipeline
Passed
2 files changed +176 −13
README.md +5 −1
@@ -30,6 +30,10 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert
30 30
31 31 ![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
32 32
33 ### Meerdere meshes en skins (glTF/GLB)
34
35 Een geskind glTF/GLB-lichaam wordt volledig geladen: alle mesh-nodes en alle skins. De joints van alle skins worden samengevoegd tot één skelet (joints die in meerdere skins voorkomen tellen één keer; maximaal 128). Een mesh-node zonder skin beweegt star mee met de dichtstbijzijnde voorouder die een joint is; heeft hij die niet, dan hangt hij aan de eerste joint. Zijn rustpositie in de scène blijft daarbij exact behouden (gespiegelde transformaties draaien de driehoeksvolgorde om). Materialen blijven per primitive: basiskleur, albedotexture, alpha-cutoff en vertexkleuren.
36
33 37 ### Statische lichamen (OBJ, glTF/FBX zonder skin)
34 38
35 39 Een lichaam zonder skin kan ook als man- of vrouwmodel gekozen worden: **OBJ+MTL** (met PNG-textures naast het model), **glTF/GLB zonder `skins`** en **FBX zonder skin deformer**. Zo'n lichaam wordt getoond en gerenderd zoals het is opgeslagen, zonder skelet of animatie:
@@ -167,7 +171,7 @@ let layer = LayerMaps::from_atlas(&body, "body", "clip000-walk", 0, 60, 330)?;
167 171 ```
168 172
169 173
170 Highres is exact 8× beide assen; batch-targets zijn momenteel **1–256 pixels per as** (highres max 2048). Grotere projectinstellingen geven een duidelijke renderfout, geen stil verkleinde supersampling. Maximaal 500000 PNGs / 16 GiB geschatte ongecomprimeerde targetdata per run; output vereist hardlinks. Productie rendert de eerste skinned glTF/GLB-mesh met driehoekprimitieven, basiskleur/8-bit albedotextures en vertexkleuren. Een FBX-lichaam loopt via de genormaliseerde scene (`fbx_scene`) door dezelfde batch: alle animatiestacks × frames × 60 hoeken, dezelfde vaste camera, nekpivot en vaste zoom per lichaam, en dezelfde vier maps. Equipment hangt op de geanimeerde botten van dat FBX-skelet. Statische lichamen renderen één bindpose-sample per hoek (zie *Statische lichamen*). Live placement blijft een belichte, vereenvoudigde weergave.
174 Highres is exact 8× beide assen; batch-targets zijn momenteel **1–256 pixels per as** (highres max 2048). Grotere projectinstellingen geven een duidelijke renderfout, geen stil verkleinde supersampling. Maximaal 500000 PNGs / 16 GiB geschatte ongecomprimeerde targetdata per run; output vereist hardlinks. Productie rendert alle glTF/GLB-meshes met driehoekprimitieven (zie *Meerdere meshes en skins*), basiskleur/8-bit albedotextures en vertexkleuren. Een FBX-lichaam loopt via de genormaliseerde scene (`fbx_scene`) door dezelfde batch: alle animatiestacks × frames × 60 hoeken, dezelfde vaste camera, nekpivot en vaste zoom per lichaam, en dezelfde vier maps. Equipment hangt op de geanimeerde botten van dat FBX-skelet. Statische lichamen renderen één bindpose-sample per hoek (zie *Statische lichamen*). Live placement blijft een belichte, vereenvoudigde weergave.
171 175
172 176 ## Openen met
173 177
@@ -30,6 +30,10 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert
30
30
31![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
31![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
32
32
33### Meerdere meshes en skins (glTF/GLB)
34
35Een geskind glTF/GLB-lichaam wordt volledig geladen: alle mesh-nodes en alle skins. De joints van alle skins worden samengevoegd tot één skelet (joints die in meerdere skins voorkomen tellen één keer; maximaal 128). Een mesh-node zonder skin beweegt star mee met de dichtstbijzijnde voorouder die een joint is; heeft hij die niet, dan hangt hij aan de eerste joint. Zijn rustpositie in de scène blijft daarbij exact behouden (gespiegelde transformaties draaien de driehoeksvolgorde om). Materialen blijven per primitive: basiskleur, albedotexture, alpha-cutoff en vertexkleuren.
36
33### Statische lichamen (OBJ, glTF/FBX zonder skin)
37### Statische lichamen (OBJ, glTF/FBX zonder skin)
34
38
35Een lichaam zonder skin kan ook als man- of vrouwmodel gekozen worden: **OBJ+MTL** (met PNG-textures naast het model), **glTF/GLB zonder `skins`** en **FBX zonder skin deformer**. Zo'n lichaam wordt getoond en gerenderd zoals het is opgeslagen, zonder skelet of animatie:
39Een lichaam zonder skin kan ook als man- of vrouwmodel gekozen worden: **OBJ+MTL** (met PNG-textures naast het model), **glTF/GLB zonder `skins`** en **FBX zonder skin deformer**. Zo'n lichaam wordt getoond en gerenderd zoals het is opgeslagen, zonder skelet of animatie:
@@ -167,7 +171,7 @@ let layer = LayerMaps::from_atlas(&body, "body", "clip000-walk", 0, 60, 330)?;
167```
171```
168
172
169
173
170Highres is exact 8× beide assen; batch-targets zijn momenteel **1–256 pixels per as** (highres max 2048). Grotere projectinstellingen geven een duidelijke renderfout, geen stil verkleinde supersampling. Maximaal 500000 PNGs / 16 GiB geschatte ongecomprimeerde targetdata per run; output vereist hardlinks. Productie rendert de eerste skinned glTF/GLB-mesh met driehoekprimitieven, basiskleur/8-bit albedotextures en vertexkleuren. Een FBX-lichaam loopt via de genormaliseerde scene (`fbx_scene`) door dezelfde batch: alle animatiestacks × frames × 60 hoeken, dezelfde vaste camera, nekpivot en vaste zoom per lichaam, en dezelfde vier maps. Equipment hangt op de geanimeerde botten van dat FBX-skelet. Statische lichamen renderen één bindpose-sample per hoek (zie *Statische lichamen*). Live placement blijft een belichte, vereenvoudigde weergave.
174Highres is exact 8× beide assen; batch-targets zijn momenteel **1–256 pixels per as** (highres max 2048). Grotere projectinstellingen geven een duidelijke renderfout, geen stil verkleinde supersampling. Maximaal 500000 PNGs / 16 GiB geschatte ongecomprimeerde targetdata per run; output vereist hardlinks. Productie rendert alle glTF/GLB-meshes met driehoekprimitieven (zie *Meerdere meshes en skins*), basiskleur/8-bit albedotextures en vertexkleuren. Een FBX-lichaam loopt via de genormaliseerde scene (`fbx_scene`) door dezelfde batch: alle animatiestacks × frames × 60 hoeken, dezelfde vaste camera, nekpivot en vaste zoom per lichaam, en dezelfde vier maps. Equipment hangt op de geanimeerde botten van dat FBX-skelet. Statische lichamen renderen één bindpose-sample per hoek (zie *Statische lichamen*). Live placement blijft een belichte, vereenvoudigde weergave.
171
175
172## Openen met
176## Openen met
173
177
src/preview.rs +171 −12
@@ -518,19 +518,49 @@ pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
518 518 skin.joints().len()
519 519 ));
520 520 }
521 let joint_nodes: Vec<usize> = skin.joints().map(|joint| joint.index()).collect();
521 // Union of all skins' joints (first skin first); every skinned mesh node
522 // is remapped onto it, so multi-mesh / multi-skin bodies draw completely.
523 let mut joint_nodes: Vec<usize> = Vec::new();
524 let mut inverse_binds: Vec<Mat4> = Vec::new();
525 let mut skin_maps: Vec<Vec<u16>> = Vec::new();
526 for s in document.skins() {
527 let reader = s.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
528 let binds: Vec<Mat4> = reader
529 .read_inverse_bind_matrices()
530 .map(|iter| iter.map(columns_to_mat).collect())
531 .unwrap_or_else(|| vec![identity(); s.joints().len()]);
532 if binds.len() != s.joints().len() {
533 return Err("inverse-bindmatrices en joints verschillen".to_string());
534 }
535 let mut map = Vec::new();
536 for (joint, bind) in s.joints().zip(binds) {
537 let index = match joint_nodes.iter().position(|n| *n == joint.index()) {
538 Some(i) => i,
539 None => {
540 joint_nodes.push(joint.index());
541 inverse_binds.push(bind);
542 joint_nodes.len() - 1
543 }
544 };
545 map.push(index as u16);
546 }
547 skin_maps.push(map);
548 }
549 if joint_nodes.len() > MAX_JOINTS {
550 return Err(format!(
551 "het skeleton heeft {} joints; de preview stopt bij {MAX_JOINTS}",
552 joint_nodes.len()
553 ));
554 }
522 555 let joint_names: Vec<String> = joint_nodes
523 556 .iter()
524 557 .map(|index| nodes[*index].name.clone())
525 558 .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 }
559 let rest_locals: Vec<Mat4> = nodes
560 .iter()
561 .map(|n| trs(n.translation, n.rotation, n.scale))
562 .collect();
563 let rest_world = world_matrices(&nodes, &rest_locals);
534 564 let mut positions = Vec::new();
535 565 let mut normals = Vec::new();
536 566 let mut source_normals_complete = true;
@@ -541,11 +571,67 @@ pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
541 571 let mut colors = Vec::new();
542 572 let mut uvs = Vec::new();
543 573 let mut surfaces = Vec::new();
544 if let Some(mesh) = document.meshes().next() {
574 for node in document.nodes() {
575 let Some(mesh) = node.mesh() else { continue };
576 // Skinned: vertices are in skin space, joints remapped onto the union.
577 // Rigid (no skin): follows its nearest joint ancestor (or the first
578 // root joint), with the rest-pose node transform baked in once.
579 let rigid = match node.skin() {
580 Some(s) => Err(&skin_maps[s.index()]),
581 None => {
582 let mut current = Some(node.index());
583 let mut joint = None;
584 let mut guard = 0;
585 while let Some(n) = current {
586 if let Some(j) = joint_nodes.iter().position(|x| *x == n) {
587 joint = Some(j);
588 break;
589 }
590 current = nodes[n].parent;
591 guard += 1;
592 if guard > nodes.len() {
593 return Err("glTF-nodehiërarchie bevat een cyclus".into());
594 }
595 }
596 let j = joint.unwrap_or(0);
597 let to_joint = affine_inverse(&rest_world[joint_nodes[j]])
598 .and_then(|jr| affine_inverse(&inverse_binds[j]).map(|b| mul(b, jr)))
599 .ok_or("singuliere joint-rustmatrix")?;
600 Ok((j as u16, mul(to_joint, rest_world[node.index()])))
601 }
602 };
545 603 for primitive in mesh.primitives() {
546 let prim = read_gltf_primitive(&primitive, &buffers, &images)?;
547 source_normals_complete &= prim.normals.is_some();
604 let mut prim = read_gltf_primitive(&primitive, &buffers, &images)?;
548 605 let count = prim.positions.len();
606 match &rigid {
607 Err(map) => {
608 for j in prim.joints.iter_mut() {
609 for k in j.iter_mut() {
610 *k = *map.get(*k as usize).ok_or("skinjoint buiten skin")?;
611 }
612 }
613 }
614 Ok((joint, m)) => {
615 let nm =
616 normal_matrix(m).ok_or("glTF-node heeft een singuliere transformatie")?;
617 for p in prim.positions.iter_mut() {
618 *p = transform_point3(m, *p);
619 }
620 if let Some(ns) = prim.normals.as_mut() {
621 for n in ns.iter_mut() {
622 *n = normalize3(mat3_mul(&nm, *n));
623 }
624 }
625 if det3(m) < 0. {
626 for tri in prim.indices.chunks_exact_mut(3) {
627 tri.swap(1, 2);
628 }
629 }
630 prim.joints = vec![[*joint, 0, 0, 0]; count];
631 prim.weights = vec![[1., 0., 0., 0.]; count];
632 }
633 }
634 source_normals_complete &= prim.normals.is_some();
549 635 let first = indices.len();
550 636 let base = positions.len() as u32;
551 637 indices.extend(prim.indices.iter().map(|index| index + base));
@@ -857,6 +943,23 @@ fn load_static_gltf(
857 943 acc.finish("glTF", reason)
858 944 }
859 945
946 /// Inverse of an affine matrix (None when singular).
947 fn affine_inverse(m: &Mat4) -> Option<Mat4> {
948 let it = normal_matrix(m)?; // inverse-transpose of the 3×3
949 // inverse 3×3 (row r, col c) = it[c][r]
950 let mut out = identity();
951 for r in 0..3 {
952 for c in 0..3 {
953 out[c * 4 + r] = it[c][r];
954 }
955 }
956 let t = [m[12], m[13], m[14]];
957 for r in 0..3 {
958 out[12 + r] = -(0..3).map(|c| out[c * 4 + r] * t[c]).sum::<f32>();
959 }
960 Some(out)
961 }
962
860 963 fn transform_point3(m: &Mat4, p: [f32; 3]) -> [f32; 3] {
861 964 [
862 965 m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
@@ -1488,6 +1591,62 @@ mod tests {
1488 1591 path
1489 1592 }
1490 1593
1594 #[test]
1595 fn unskinned_mesh_nodes_follow_their_joint_ancestor() {
1596 let base = load_gltf_file(
1597 &std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1598 .join("tests/fixtures/bodies/test-body-male.glb"),
1599 )
1600 .unwrap();
1601 let path = glb_with(|d| {
1602 let nodes = d["nodes"].as_array_mut().unwrap();
1603 nodes.push(serde_json::json!({"mesh": 0, "translation": [0.0, 0.5, 0.2]}));
1604 let kids = nodes[4]["children"].as_array().cloned().unwrap_or_default();
1605 let mut kids = kids;
1606 kids.push(serde_json::json!(20));
1607 nodes[4]["children"] = serde_json::Value::Array(kids);
1608 });
1609 let both = load_gltf_file(&path).unwrap();
1610 let n = base.positions.len();
1611 assert_eq!(both.positions.len(), 2 * n);
1612 let j = both.joint_nodes.iter().position(|x| *x == 4).unwrap() as u16;
1613 assert!(both.joints[n..].iter().all(|k| k[0] == j));
1614 assert!(both.weights[n..].iter().all(|w| w[0] == 1.0));
1615 // At rest, skinning reproduces the node's world placement.
1616 let locals: Vec<Mat4> = both
1617 .nodes
1618 .iter()
1619 .map(|x| trs(x.translation, x.rotation, x.scale))
1620 .collect();
1621 let world = world_matrices(&both.nodes, &locals);
1622 let skin = mul(world[4], both.inverse_binds[j as usize]);
1623 for (k, p) in both.positions[n..].iter().enumerate().take(20) {
1624 let got = transform_point3(&skin, *p);
1625 // Source vertex = base mesh vertex in skin space; world = node20 * v.
1626 let v = base.positions[k];
1627 let want = transform_point3(&world[20], v);
1628 for a in 0..3 {
1629 assert!((got[a] - want[a]).abs() < 1e-3, "{got:?} vs {want:?}");
1630 }
1631 }
1632 let _ = std::fs::remove_file(path);
1633 }
1634
1635 #[test]
1636 fn affine_inverse_round_trips() {
1637 let m = trs(
1638 [1., 2., 3.],
1639 [0.0, 0.38268343, 0.0, 0.9238795],
1640 [2., 1., 0.5],
1641 );
1642 let i = affine_inverse(&m).unwrap();
1643 let id = mul(m, i);
1644 for (k, v) in id.iter().enumerate() {
1645 let want = if k % 5 == 0 { 1.0 } else { 0.0 };
1646 assert!((v - want).abs() < 1e-5);
1647 }
1648 }
1649
1491 1650 #[test]
1492 1651 fn gltf_step_is_kept_bad_cubic_rejected_unnamed_clips_get_index_names() {
1493 1652 let base = load_gltf_file(
@@ -518,19 +518,49 @@ pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
518 skin.joints().len()
518 skin.joints().len()
519 ));
519 ));
520 }
520 }
521 let joint_nodes: Vec<usize> = skin.joints().map(|joint| joint.index()).collect();
521 // Union of all skins' joints (first skin first); every skinned mesh node
522 // is remapped onto it, so multi-mesh / multi-skin bodies draw completely.
523 let mut joint_nodes: Vec<usize> = Vec::new();
524 let mut inverse_binds: Vec<Mat4> = Vec::new();
525 let mut skin_maps: Vec<Vec<u16>> = Vec::new();
526 for s in document.skins() {
527 let reader = s.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
528 let binds: Vec<Mat4> = reader
529 .read_inverse_bind_matrices()
530 .map(|iter| iter.map(columns_to_mat).collect())
531 .unwrap_or_else(|| vec![identity(); s.joints().len()]);
532 if binds.len() != s.joints().len() {
533 return Err("inverse-bindmatrices en joints verschillen".to_string());
534 }
535 let mut map = Vec::new();
536 for (joint, bind) in s.joints().zip(binds) {
537 let index = match joint_nodes.iter().position(|n| *n == joint.index()) {
538 Some(i) => i,
539 None => {
540 joint_nodes.push(joint.index());
541 inverse_binds.push(bind);
542 joint_nodes.len() - 1
543 }
544 };
545 map.push(index as u16);
546 }
547 skin_maps.push(map);
548 }
549 if joint_nodes.len() > MAX_JOINTS {
550 return Err(format!(
551 "het skeleton heeft {} joints; de preview stopt bij {MAX_JOINTS}",
552 joint_nodes.len()
553 ));
554 }
522 let joint_names: Vec<String> = joint_nodes
555 let joint_names: Vec<String> = joint_nodes
523 .iter()
556 .iter()
524 .map(|index| nodes[*index].name.clone())
557 .map(|index| nodes[*index].name.clone())
525 .collect();
558 .collect();
526 let reader = skin.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
559 let rest_locals: Vec<Mat4> = nodes
527 let inverse_binds = reader
560 .iter()
528 .read_inverse_bind_matrices()
561 .map(|n| trs(n.translation, n.rotation, n.scale))
529 .map(|iter| iter.map(columns_to_mat).collect())
562 .collect();
530 .unwrap_or_else(|| vec![identity(); joint_nodes.len()]);
563 let rest_world = world_matrices(&nodes, &rest_locals);
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();
564 let mut positions = Vec::new();
535 let mut normals = Vec::new();
565 let mut normals = Vec::new();
536 let mut source_normals_complete = true;
566 let mut source_normals_complete = true;
@@ -541,11 +571,67 @@ pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
541 let mut colors = Vec::new();
571 let mut colors = Vec::new();
542 let mut uvs = Vec::new();
572 let mut uvs = Vec::new();
543 let mut surfaces = Vec::new();
573 let mut surfaces = Vec::new();
544 if let Some(mesh) = document.meshes().next() {
574 for node in document.nodes() {
575 let Some(mesh) = node.mesh() else { continue };
576 // Skinned: vertices are in skin space, joints remapped onto the union.
577 // Rigid (no skin): follows its nearest joint ancestor (or the first
578 // root joint), with the rest-pose node transform baked in once.
579 let rigid = match node.skin() {
580 Some(s) => Err(&skin_maps[s.index()]),
581 None => {
582 let mut current = Some(node.index());
583 let mut joint = None;
584 let mut guard = 0;
585 while let Some(n) = current {
586 if let Some(j) = joint_nodes.iter().position(|x| *x == n) {
587 joint = Some(j);
588 break;
589 }
590 current = nodes[n].parent;
591 guard += 1;
592 if guard > nodes.len() {
593 return Err("glTF-nodehiërarchie bevat een cyclus".into());
594 }
595 }
596 let j = joint.unwrap_or(0);
597 let to_joint = affine_inverse(&rest_world[joint_nodes[j]])
598 .and_then(|jr| affine_inverse(&inverse_binds[j]).map(|b| mul(b, jr)))
599 .ok_or("singuliere joint-rustmatrix")?;
600 Ok((j as u16, mul(to_joint, rest_world[node.index()])))
601 }
602 };
545 for primitive in mesh.primitives() {
603 for primitive in mesh.primitives() {
546 let prim = read_gltf_primitive(&primitive, &buffers, &images)?;
604 let mut prim = read_gltf_primitive(&primitive, &buffers, &images)?;
547 source_normals_complete &= prim.normals.is_some();
548 let count = prim.positions.len();
605 let count = prim.positions.len();
606 match &rigid {
607 Err(map) => {
608 for j in prim.joints.iter_mut() {
609 for k in j.iter_mut() {
610 *k = *map.get(*k as usize).ok_or("skinjoint buiten skin")?;
611 }
612 }
613 }
614 Ok((joint, m)) => {
615 let nm =
616 normal_matrix(m).ok_or("glTF-node heeft een singuliere transformatie")?;
617 for p in prim.positions.iter_mut() {
618 *p = transform_point3(m, *p);
619 }
620 if let Some(ns) = prim.normals.as_mut() {
621 for n in ns.iter_mut() {
622 *n = normalize3(mat3_mul(&nm, *n));
623 }
624 }
625 if det3(m) < 0. {
626 for tri in prim.indices.chunks_exact_mut(3) {
627 tri.swap(1, 2);
628 }
629 }
630 prim.joints = vec![[*joint, 0, 0, 0]; count];
631 prim.weights = vec![[1., 0., 0., 0.]; count];
632 }
633 }
634 source_normals_complete &= prim.normals.is_some();
549 let first = indices.len();
635 let first = indices.len();
550 let base = positions.len() as u32;
636 let base = positions.len() as u32;
551 indices.extend(prim.indices.iter().map(|index| index + base));
637 indices.extend(prim.indices.iter().map(|index| index + base));
@@ -857,6 +943,23 @@ fn load_static_gltf(
857 acc.finish("glTF", reason)
943 acc.finish("glTF", reason)
858}
944}
859
945
946/// Inverse of an affine matrix (None when singular).
947fn affine_inverse(m: &Mat4) -> Option<Mat4> {
948 let it = normal_matrix(m)?; // inverse-transpose of the 3×3
949 // inverse 3×3 (row r, col c) = it[c][r]
950 let mut out = identity();
951 for r in 0..3 {
952 for c in 0..3 {
953 out[c * 4 + r] = it[c][r];
954 }
955 }
956 let t = [m[12], m[13], m[14]];
957 for r in 0..3 {
958 out[12 + r] = -(0..3).map(|c| out[c * 4 + r] * t[c]).sum::<f32>();
959 }
960 Some(out)
961}
962
860fn transform_point3(m: &Mat4, p: [f32; 3]) -> [f32; 3] {
963fn transform_point3(m: &Mat4, p: [f32; 3]) -> [f32; 3] {
861 [
964 [
862 m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
965 m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
@@ -1488,6 +1591,62 @@ mod tests {
1488 path
1591 path
1489 }
1592 }
1490
1593
1594 #[test]
1595 fn unskinned_mesh_nodes_follow_their_joint_ancestor() {
1596 let base = load_gltf_file(
1597 &std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1598 .join("tests/fixtures/bodies/test-body-male.glb"),
1599 )
1600 .unwrap();
1601 let path = glb_with(|d| {
1602 let nodes = d["nodes"].as_array_mut().unwrap();
1603 nodes.push(serde_json::json!({"mesh": 0, "translation": [0.0, 0.5, 0.2]}));
1604 let kids = nodes[4]["children"].as_array().cloned().unwrap_or_default();
1605 let mut kids = kids;
1606 kids.push(serde_json::json!(20));
1607 nodes[4]["children"] = serde_json::Value::Array(kids);
1608 });
1609 let both = load_gltf_file(&path).unwrap();
1610 let n = base.positions.len();
1611 assert_eq!(both.positions.len(), 2 * n);
1612 let j = both.joint_nodes.iter().position(|x| *x == 4).unwrap() as u16;
1613 assert!(both.joints[n..].iter().all(|k| k[0] == j));
1614 assert!(both.weights[n..].iter().all(|w| w[0] == 1.0));
1615 // At rest, skinning reproduces the node's world placement.
1616 let locals: Vec<Mat4> = both
1617 .nodes
1618 .iter()
1619 .map(|x| trs(x.translation, x.rotation, x.scale))
1620 .collect();
1621 let world = world_matrices(&both.nodes, &locals);
1622 let skin = mul(world[4], both.inverse_binds[j as usize]);
1623 for (k, p) in both.positions[n..].iter().enumerate().take(20) {
1624 let got = transform_point3(&skin, *p);
1625 // Source vertex = base mesh vertex in skin space; world = node20 * v.
1626 let v = base.positions[k];
1627 let want = transform_point3(&world[20], v);
1628 for a in 0..3 {
1629 assert!((got[a] - want[a]).abs() < 1e-3, "{got:?} vs {want:?}");
1630 }
1631 }
1632 let _ = std::fs::remove_file(path);
1633 }
1634
1635 #[test]
1636 fn affine_inverse_round_trips() {
1637 let m = trs(
1638 [1., 2., 3.],
1639 [0.0, 0.38268343, 0.0, 0.9238795],
1640 [2., 1., 0.5],
1641 );
1642 let i = affine_inverse(&m).unwrap();
1643 let id = mul(m, i);
1644 for (k, v) in id.iter().enumerate() {
1645 let want = if k % 5 == 0 { 1.0 } else { 0.0 };
1646 assert!((v - want).abs() < 1e-5);
1647 }
1648 }
1649
1491 #[test]
1650 #[test]
1492 fn gltf_step_is_kept_bad_cubic_rejected_unnamed_clips_get_index_names() {
1651 fn gltf_step_is_kept_bad_cubic_rejected_unnamed_clips_get_index_names() {
1493 let base = load_gltf_file(
1652 let base = load_gltf_file(