glTF: load all mesh nodes and skins (joint union), rigid unskinned meshes follow joint ancestor
@@ -30,6 +30,10 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert
30
30
31
31

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** selecteert30
30
31
31
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
### Statische lichamen (OBJ, glTF/FBX zonder skin)37
### Statische lichamen (OBJ, glTF/FBX zonder skin)34
38
35
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: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
## Openen met176
## Openen met173
177
@@ -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 node522
// 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> = reader529
.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() - 1543
}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_nodes555
let joint_names: Vec<String> = joint_nodes523
.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> = nodes527
let inverse_binds = reader560
.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 first578
// 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).947
fn affine_inverse(m: &Mat4) -> Option<Mat4> {948
let it = normal_matrix(m)?; // inverse-transpose of the 3×3949
// 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
fn transform_point3(m: &Mat4, p: [f32; 3]) -> [f32; 3] {963
fn 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
path1591
path1489
}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> = both1617
.nodes1618
.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(