BlueGit

golden-age paperdoll-renderer

Merge feature/anim-orientation-fixups (Tim t965u animation/orientation fixes)

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 7 days ago

Hash
973d0a4392743fd85506c9d82fb256889ddc2fbd
7 files changed +603 −6
README.md +8 −0
@@ -30,6 +30,14 @@ 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 ### Automatische correcties bij het laden
34
35 Na het laden controleert de editor elk lichaam en toont gevonden correcties als blauwe regel *Automatische correctie: …* in het body-scherm. De correcties gelden in de live preview, stills, productie en voor equipment:
36
37 - **Kijkrichting.** De renderer gaat uit van een lichaam dat naar −Z kijkt (yaw 0 = voorkant). Steken de voeten (onderste 4% van de lichaamshoogte) duidelijk verder naar +Z uit dan de enkels (6–10%), zoals bij de glTF-standaard, dan wordt het lichaam 180° om Y gedraaid. Bij minder dan 1000 vertices (blokvormige proxy's) of een onduidelijk verschil blijft de oriëntatie zoals in de bron.
38 - **Losse voeten.** Bij IK-rigs hangen de voetbotten soms onder de root in plaats van onder het onderbeen. Begint zo'n voetketen precies aan het eind van een geanimeerd eindbot (bijvoorbeeld `calf`), en heeft de keten zelf geen animatie, dan beweegt hij star mee met dat bot, zoals bij gewone FK.
39 - **Voor/achter gespiegelde beweging.** Buigen de knieën in vrijwel alle gemeten standen (minstens 4, en meer dan 3× zo vaak verkeerd als goed) naar achteren ten opzichte van de voorkant van het lichaam, dan wordt de beweging voor/achter gespiegeld rond de rustpose. De rustpose zelf verandert niet.
40
33 41 ### Meerdere meshes en skins (glTF/GLB)
34 42
35 43 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. `KHR_texture_transform` op de basiskleurtexture (offset, rotatie, schaal en `texCoord`) wordt bij het laden één keer in de UV's verwerkt. De sampler-wrapmodus wordt niet gelezen: textures worden altijd met nearest en herhalen (REPEAT) bemonsterd, dus CLAMP/MIRROR geven buiten het UV-bereik 0–1 een ander beeld. Metallic/roughness-, normal-, occlusion- en emissive-textures en morph targets worden niet gebruikt; de normal map komt uit de geometrie en de specular-map van een body is nul (metallic/roughness wordt niet omgerekend; een scalaire specular kan PBR niet fysiek nabootsen).
@@ -30,6 +30,14 @@ 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### Automatische correcties bij het laden
34
35Na het laden controleert de editor elk lichaam en toont gevonden correcties als blauwe regel *Automatische correctie: …* in het body-scherm. De correcties gelden in de live preview, stills, productie en voor equipment:
36
37- **Kijkrichting.** De renderer gaat uit van een lichaam dat naar −Z kijkt (yaw 0 = voorkant). Steken de voeten (onderste 4% van de lichaamshoogte) duidelijk verder naar +Z uit dan de enkels (6–10%), zoals bij de glTF-standaard, dan wordt het lichaam 180° om Y gedraaid. Bij minder dan 1000 vertices (blokvormige proxy's) of een onduidelijk verschil blijft de oriëntatie zoals in de bron.
38- **Losse voeten.** Bij IK-rigs hangen de voetbotten soms onder de root in plaats van onder het onderbeen. Begint zo'n voetketen precies aan het eind van een geanimeerd eindbot (bijvoorbeeld `calf`), en heeft de keten zelf geen animatie, dan beweegt hij star mee met dat bot, zoals bij gewone FK.
39- **Voor/achter gespiegelde beweging.** Buigen de knieën in vrijwel alle gemeten standen (minstens 4, en meer dan 3× zo vaak verkeerd als goed) naar achteren ten opzichte van de voorkant van het lichaam, dan wordt de beweging voor/achter gespiegeld rond de rustpose. De rustpose zelf verandert niet.
40
33### Meerdere meshes en skins (glTF/GLB)
41### Meerdere meshes en skins (glTF/GLB)
34
42
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. `KHR_texture_transform` op de basiskleurtexture (offset, rotatie, schaal en `texCoord`) wordt bij het laden één keer in de UV's verwerkt. De sampler-wrapmodus wordt niet gelezen: textures worden altijd met nearest en herhalen (REPEAT) bemonsterd, dus CLAMP/MIRROR geven buiten het UV-bereik 0–1 een ander beeld. Metallic/roughness-, normal-, occlusion- en emissive-textures en morph targets worden niet gebruikt; de normal map komt uit de geometrie en de specular-map van een body is nul (metallic/roughness wordt niet omgerekend; een scalaire specular kan PBR niet fysiek nabootsen).
43Een 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. `KHR_texture_transform` op de basiskleurtexture (offset, rotatie, schaal en `texCoord`) wordt bij het laden één keer in de UV's verwerkt. De sampler-wrapmodus wordt niet gelezen: textures worden altijd met nearest en herhalen (REPEAT) bemonsterd, dus CLAMP/MIRROR geven buiten het UV-bereik 0–1 een ander beeld. Metallic/roughness-, normal-, occlusion- en emissive-textures en morph targets worden niet gebruikt; de normal map komt uit de geometrie en de specular-map van een body is nul (metallic/roughness wordt niet omgerekend; een scalaire specular kan PBR niet fysiek nabootsen).
src/camera.rs +1 −0
@@ -669,6 +669,7 @@ mod tests {
669 669 surfaces: Vec::new(),
670 670 clips: Vec::new(),
671 671 rig: preview::BodyRig::Skinned,
672 fixups: Default::default(),
672 673 }
673 674 }
674 675
@@ -669,6 +669,7 @@ mod tests {
669 surfaces: Vec::new(),
669 surfaces: Vec::new(),
670 clips: Vec::new(),
670 clips: Vec::new(),
671 rig: preview::BodyRig::Skinned,
671 rig: preview::BodyRig::Skinned,
672 fixups: Default::default(),
672 }
673 }
673 }
674 }
674
675
src/editor.rs +8 −0
@@ -1381,6 +1381,14 @@ impl EditorApp {
1381 1381 ),
1382 1382 );
1383 1383 }
1384 if let Some(body) = self.character() {
1385 for note in &body.fixups.notes {
1386 ui.colored_label(
1387 egui::Color32::from_rgb(150, 200, 255),
1388 format!("Automatische correctie: {note}"),
1389 );
1390 }
1391 }
1384 1392 self.framing_ui(ui, usize::from(sex == Sex::Female));
1385 1393 self.render_progress_ui(ui);
1386 1394 self.resume_ui(ui);
@@ -1381,6 +1381,14 @@ impl EditorApp {
1381 ),
1381 ),
1382 );
1382 );
1383 }
1383 }
1384 if let Some(body) = self.character() {
1385 for note in &body.fixups.notes {
1386 ui.colored_label(
1387 egui::Color32::from_rgb(150, 200, 255),
1388 format!("Automatische correctie: {note}"),
1389 );
1390 }
1391 }
1384 self.framing_ui(ui, usize::from(sex == Sex::Female));
1392 self.framing_ui(ui, usize::from(sex == Sex::Female));
1385 self.render_progress_ui(ui);
1393 self.render_progress_ui(ui);
1386 self.resume_ui(ui);
1394 self.resume_ui(ui);
src/fbx_scene.rs +1 −0
@@ -227,6 +227,7 @@ pub fn load_fbx_drawable(path: &Path) -> Result<FbxDrawableScene, String> {
227 227 surfaces,
228 228 clips,
229 229 rig: crate::preview::BodyRig::Skinned,
230 fixups: Default::default(),
230 231 },
231 232 provenance,
232 233 materials,
@@ -227,6 +227,7 @@ pub fn load_fbx_drawable(path: &Path) -> Result<FbxDrawableScene, String> {
227 surfaces,
227 surfaces,
228 clips,
228 clips,
229 rig: crate::preview::BodyRig::Skinned,
229 rig: crate::preview::BodyRig::Skinned,
230 fixups: Default::default(),
230 },
231 },
231 provenance,
232 provenance,
232 materials,
233 materials,
src/lib.rs +1 −0
@@ -23,6 +23,7 @@ pub mod file_assoc;
23 23 pub mod foundation;
24 24 pub mod icon;
25 25 pub mod import;
26 pub mod orient;
26 27 pub mod preview;
27 28 pub mod preview2d;
28 29 pub mod preview_gl;
@@ -23,6 +23,7 @@ pub mod file_assoc;
23pub mod foundation;
23pub mod foundation;
24pub mod icon;
24pub mod icon;
25pub mod import;
25pub mod import;
26pub mod orient;
26pub mod preview;
27pub mod preview;
27pub mod preview2d;
28pub mod preview2d;
28pub mod preview_gl;
29pub mod preview_gl;
src/orient.rs added +543 −0
@@ -0,0 +1,543 @@
1 //! Load-time orientation and animation corrections for source bodies.
2 //!
3 //! Applied once per body after loading, inside `preview::posed_worlds`, so
4 //! the live view, stills, production and equipment all see the same pose.
5 //!
6 //! * **Forward axis.** The renderer's convention is "character faces −Z"
7 //! (yaw 0 = front). A body whose feet point to +Z (toes in front of the
8 //! ankles, the glTF default) is turned 180° about Y.
9 //! * **Detached feet.** IK rigs often keep the foot bones under the root
10 //! instead of under the shin. When such a foot chain starts exactly at the
11 //! end of an animated leaf bone and has no animation of its own, it follows
12 //! that bone rigidly (as forward kinematics would).
13 //! * **Front/back mirrored motion.** When knees bend the wrong way relative
14 //! to the body's own front in almost every frame, the motion is mirrored
15 //! front/back (per-joint world deltas reflected through the frontal plane);
16 //! the rest pose is unchanged.
17 use crate::preview::{self, affine_inverse, mul, Mat4, PreviewCharacter};
18
19 #[derive(Debug, Clone, Default, PartialEq)]
20 pub struct BodyFixups {
21 /// Turn the whole body 180° about +Y (source faces +Z).
22 pub turn_180: bool,
23 /// Reflect the motion front/back (source-local Z) around the rest pose.
24 pub mirror_motion: bool,
25 /// (node, driver node): node's subtree follows the driver rigidly.
26 pub follow: Vec<(usize, usize)>,
27 /// Human-readable (Dutch) explanations for the editor/render log.
28 pub notes: Vec<String>,
29 }
30
31 impl BodyFixups {
32 pub fn is_identity(&self) -> bool {
33 !self.turn_180 && !self.mirror_motion && self.follow.is_empty()
34 }
35 }
36
37 /// Which way a body faces, as decided by `detect`.
38 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
39 pub enum Forward {
40 MinusZ,
41 PlusZ,
42 }
43
44 fn diag(x: f32, y: f32, z: f32) -> Mat4 {
45 let mut m = preview::identity();
46 m[0] = x;
47 m[5] = y;
48 m[10] = z;
49 m
50 }
51
52 fn descendants(character: &PreviewCharacter, root: usize) -> Vec<usize> {
53 let mut out = vec![root];
54 let mut i = 0;
55 while i < out.len() {
56 let n = out[i];
57 for (c, node) in character.nodes.iter().enumerate() {
58 if node.parent == Some(n) && !out.contains(&c) {
59 out.push(c);
60 }
61 }
62 i += 1;
63 }
64 out
65 }
66
67 fn is_ancestor(character: &PreviewCharacter, a: usize, mut n: usize) -> bool {
68 let mut guard = 0;
69 while let Some(p) = character.nodes.get(n).and_then(|x| x.parent) {
70 if p == a {
71 return true;
72 }
73 n = p;
74 guard += 1;
75 if guard > character.nodes.len() {
76 break;
77 }
78 }
79 false
80 }
81
82 /// Apply `character.fixups` to raw posed world matrices.
83 pub fn apply(character: &PreviewCharacter, worlds: Vec<Mat4>) -> Vec<Mat4> {
84 apply_with(character, &character.fixups, worlds)
85 }
86
87 fn apply_with(character: &PreviewCharacter, f: &BodyFixups, mut w: Vec<Mat4>) -> Vec<Mat4> {
88 if f.is_identity() {
89 return w;
90 }
91 let rest = preview::rest_worlds(character);
92 for &(node, driver) in &f.follow {
93 let (Some(rd), Some(rn)) = (affine_inverse(&rest[driver]), affine_inverse(&w[node])) else {
94 continue;
95 };
96 let delta = mul(w[driver], rd);
97 let new_node = mul(delta, rest[node]);
98 for d in descendants(character, node) {
99 w[d] = mul(new_node, mul(rn, w[d]));
100 }
101 }
102 if f.mirror_motion {
103 let s = diag(1., 1., -1.);
104 for (i, m) in w.iter_mut().enumerate() {
105 if let Some(ri) = affine_inverse(&rest[i]) {
106 *m = mul(s, mul(*m, mul(ri, mul(s, rest[i]))));
107 }
108 }
109 }
110 if f.turn_180 {
111 let r = diag(-1., 1., -1.);
112 for m in w.iter_mut() {
113 *m = mul(r, *m);
114 }
115 }
116 w
117 }
118
119 fn point(m: &Mat4, p: [f32; 3]) -> [f32; 3] {
120 [
121 m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
122 m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
123 m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14],
124 ]
125 }
126
127 /// Rest-pose vertex positions (bind positions through the rest skin).
128 fn rest_positions(character: &PreviewCharacter) -> Vec<[f32; 3]> {
129 if character.is_static() || character.joint_nodes.is_empty() {
130 return character.positions.clone();
131 }
132 let rest = preview::rest_worlds(character);
133 let skin: Vec<Mat4> = character
134 .joint_nodes
135 .iter()
136 .zip(&character.inverse_binds)
137 .map(|(n, ib)| mul(rest[*n], *ib))
138 .collect();
139 character
140 .positions
141 .iter()
142 .enumerate()
143 .map(|(i, p)| {
144 let w = character
145 .weights
146 .get(i)
147 .copied()
148 .unwrap_or([1., 0., 0., 0.]);
149 let j = character.joints.get(i).copied().unwrap_or([0; 4]);
150 let mut out = [0.; 3];
151 let mut sum = 0.;
152 for k in 0..4 {
153 if w[k] > 0. {
154 if let Some(m) = skin.get(j[k] as usize) {
155 let q = point(m, *p);
156 for a in 0..3 {
157 out[a] += w[k] * q[a];
158 }
159 sum += w[k];
160 }
161 }
162 }
163 if sum > 0. {
164 out.map(|v| v / sum)
165 } else {
166 *p
167 }
168 })
169 .collect()
170 }
171
172 /// Feet heuristic: the vertices of the lowest 4% of the body (feet) extend
173 /// further forward than those at 6–10% (ankles/shins). None when unclear
174 /// (meshes under 1000 vertices such as box proxies, fewer than 12 vertices per
175 /// band, or a difference under 1% of height).
176 pub fn detect_forward(positions: &[[f32; 3]]) -> Option<Forward> {
177 let (lo, hi) = positions
178 .iter()
179 .fold((f32::INFINITY, f32::NEG_INFINITY), |(a, b), p| {
180 (a.min(p[1]), b.max(p[1]))
181 });
182 let h = hi - lo;
183 if !(h.is_finite() && h > 0.) || positions.len() < 1000 {
184 return None;
185 }
186 let mean = |a: f32, b: f32| {
187 let v: Vec<f32> = positions
188 .iter()
189 .filter(|p| p[1] >= lo + a * h && p[1] <= lo + b * h)
190 .map(|p| p[2])
191 .collect();
192 // Too few vertices (box-like proxies) give no reliable feet shape.
193 (v.len() >= 12).then(|| v.iter().sum::<f32>() / v.len() as f32)
194 };
195 let feet = mean(0., 0.04)?;
196 let ankles = mean(0.06, 0.10)?;
197 let d = feet - ankles;
198 if d > 0.01 * h {
199 Some(Forward::PlusZ)
200 } else if d < -0.01 * h {
201 Some(Forward::MinusZ)
202 } else {
203 None
204 }
205 }
206
207 fn channel_static(ch: &preview::AnimChannel) -> bool {
208 let n = ch.property.width();
209 let first: Vec<f32> = ch.values.iter().take(n).copied().collect();
210 ch.values
211 .chunks(n.max(1))
212 .all(|k| k.iter().zip(&first).all(|(a, b)| (a - b).abs() < 1e-5))
213 }
214
215 fn node_animated(character: &PreviewCharacter, node: usize) -> bool {
216 character.clips.iter().any(|c| {
217 c.channels
218 .iter()
219 .any(|ch| ch.node == node && !channel_static(ch))
220 })
221 }
222
223 fn node_matches_rest(character: &PreviewCharacter, node: usize) -> bool {
224 // Static channels must also equal the rest pose (else the bone is posed).
225 let n = &character.nodes[node];
226 character.clips.iter().all(|c| {
227 c.channels.iter().filter(|ch| ch.node == node).all(|ch| {
228 let rest: Vec<f32> = match ch.property {
229 preview::ChannelProperty::Translation => n.translation.to_vec(),
230 preview::ChannelProperty::Rotation => n.rotation.to_vec(),
231 preview::ChannelProperty::Scale => n.scale.to_vec(),
232 };
233 let k = &ch.values[..rest.len().min(ch.values.len())];
234 let dot: f32 = k.iter().zip(&rest).map(|(a, b)| a * b).sum();
235 match ch.property {
236 preview::ChannelProperty::Rotation => dot.abs() > 0.9999,
237 _ => k.iter().zip(&rest).all(|(a, b)| (a - b).abs() < 1e-4),
238 }
239 })
240 })
241 }
242
243 /// Detect all fixups for a freshly loaded body.
244 pub fn detect(character: &PreviewCharacter) -> BodyFixups {
245 let mut f = BodyFixups::default();
246 let positions = rest_positions(character);
247 let forward = detect_forward(&positions);
248 if character.is_static() {
249 return f;
250 }
251 let rest = preview::rest_worlds(character);
252 let (lo, hi) = positions
253 .iter()
254 .fold((f32::INFINITY, f32::NEG_INFINITY), |(a, b), p| {
255 (a.min(p[1]), b.max(p[1]))
256 });
257 let h = (hi - lo).max(1e-3);
258 let joints = &character.joint_nodes;
259 let has_joint_child = |n: usize| joints.iter().any(|c| character.nodes[*c].parent == Some(n));
260 // Detached feet.
261 for &driver in joints {
262 if has_joint_child(driver) || !node_animated(character, driver) {
263 continue;
264 }
265 let Some(inv) = affine_inverse(&rest[driver]) else {
266 continue;
267 };
268 for &cand in joints {
269 if cand == driver
270 || is_ancestor(character, cand, driver)
271 || is_ancestor(character, driver, cand)
272 {
273 continue;
274 }
275 let o = [rest[cand][12], rest[cand][13], rest[cand][14]];
276 let l = point(&inv, o);
277 // Rest origin on the driver's bone axis (+Y), beyond its head.
278 let len = (rest[driver][4].powi(2) + rest[driver][5].powi(2) + rest[driver][6].powi(2))
279 .sqrt();
280 let along = l[1] * len;
281 if !(l[0].abs() * len < 0.003 * h
282 && l[2].abs() * len < 0.003 * h
283 && along > 0.05 * h
284 && along < 0.5 * h)
285 {
286 continue;
287 }
288 // Climb to the topmost ancestor that is not above the driver.
289 let mut top = cand;
290 while let Some(p) = character.nodes[top].parent {
291 if is_ancestor(character, p, driver) || p == driver {
292 break;
293 }
294 top = p;
295 }
296 let subtree = descendants(character, top);
297 if subtree
298 .iter()
299 .any(|n| node_animated(character, *n) || !node_matches_rest(character, *n))
300 {
301 continue;
302 }
303 if !f.follow.iter().any(|(n, _)| *n == top) {
304 f.follow.push((top, driver));
305 f.notes.push(format!(
306 "{} volgt {} (losse voetketen zonder eigen animatie)",
307 character.nodes[top].name, character.nodes[driver].name
308 ));
309 }
310 }
311 }
312 // Knee direction vs. the body's own front.
313 let front = match forward {
314 Some(Forward::PlusZ) => 1.,
315 _ => -1.,
316 };
317 let mut legs: Vec<(usize, usize, [f32; 3])> = Vec::new(); // (thigh node, shin node, ankle in shin space)
318 for &(node, driver) in &f.follow {
319 if let (Some(p), Some(inv)) = (
320 character.nodes[driver].parent,
321 affine_inverse(&rest[driver]),
322 ) {
323 legs.push((
324 p,
325 driver,
326 point(&inv, [rest[node][12], rest[node][13], rest[node][14]]),
327 ));
328 }
329 }
330 let (mut good, mut bad) = (0, 0);
331 if !legs.is_empty() {
332 let partial = BodyFixups {
333 follow: f.follow.clone(),
334 ..Default::default()
335 };
336 for clip in &character.clips {
337 let frames = ((clip.duration * 24.).ceil() as usize).max(1);
338 for i in 0..frames {
339 let w = apply_with(
340 character,
341 &partial,
342 preview::raw_posed_worlds(character, &clip.name, i as f32 / 24.),
343 );
344 for (thigh, shin, ankle_local) in &legs {
345 let hip = [w[*thigh][12], w[*thigh][13], w[*thigh][14]];
346 let knee = [w[*shin][12], w[*shin][13], w[*shin][14]];
347 let ankle = point(&w[*shin], *ankle_local);
348 let dy = ankle[1] - hip[1];
349 if dy.abs() < 1e-4 {
350 continue;
351 }
352 let t = (knee[1] - hip[1]) / dy;
353 let line_z = hip[2] + t * (ankle[2] - hip[2]);
354 let off = (knee[2] - line_z) * front;
355 if off > 0.02 * h {
356 good += 1;
357 } else if off < -0.02 * h {
358 bad += 1;
359 }
360 }
361 }
362 }
363 }
364 if bad >= 4 && bad > 3 * good {
365 f.mirror_motion = true;
366 f.notes.push(format!(
367 "beweging voor/achter gespiegeld: knieën bogen in {bad} van {} gemeten standen de verkeerde kant op",
368 bad + good
369 ));
370 }
371 if forward == Some(Forward::PlusZ) {
372 f.turn_180 = true;
373 f.notes.push("lichaam kijkt naar +Z (tenen voor de enkels): 180° gedraaid zodat yaw 0 de voorkant toont".into());
374 }
375 f
376 }
377
378 /// Detect and store the fixups on a loaded body. Static bodies (no bones)
379 /// get their +Z-facing geometry turned directly.
380 pub fn finalize(mut character: PreviewCharacter) -> PreviewCharacter {
381 if character.is_static() {
382 if detect_forward(&character.positions) == Some(Forward::PlusZ) {
383 for p in character
384 .positions
385 .iter_mut()
386 .chain(character.normals.iter_mut())
387 {
388 p[0] = -p[0];
389 p[2] = -p[2];
390 }
391 character.fixups.notes.push(
392 "lichaam kijkt naar +Z (tenen voor de enkels): 180° gedraaid zodat yaw 0 de voorkant toont".into(),
393 );
394 }
395 return character;
396 }
397 character.fixups = detect(&character);
398 character
399 }
400
401 /// Forward axis of a body's rest pose (None when unclear).
402 pub fn forward_of(character: &PreviewCharacter) -> Option<Forward> {
403 detect_forward(&rest_positions(character))
404 }
405
406 #[cfg(test)]
407 mod tests {
408 use super::*;
409 use crate::preview::{
410 AnimChannel, BodyRig, ChannelProperty, Interpolation, PreviewClip, SceneNode,
411 };
412
413 fn node(name: &str, parent: Option<usize>, t: [f32; 3], r: [f32; 4]) -> SceneNode {
414 SceneNode {
415 name: name.into(),
416 parent,
417 translation: t,
418 rotation: r,
419 scale: [1.; 3],
420 }
421 }
422
423 /// Leg whose foot control hangs under the root (IK style), with a knee
424 /// that bends by `knee_deg` (negative = shin back = natural for −Z front).
425 fn ik_leg(knee_deg: f32) -> PreviewCharacter {
426 let half = (knee_deg.to_radians() / 2.).sin_cos();
427 let nodes = vec![
428 node("root", None, [0., 0., 0.], [0., 0., 0., 1.]),
429 node("thigh", Some(0), [0., 1., 0.], [1., 0., 0., 0.]),
430 node("calf", Some(1), [0., 0.5, 0.], [0., 0., 0., 1.]),
431 node("footControl", Some(0), [0., 0.05, 0.], [0., 0., 0., 1.]),
432 node("toes", Some(3), [0., 0., -0.1], [0., 0., 0., 1.]),
433 ];
434 let clip = PreviewClip {
435 name: "walk".into(),
436 duration: 1.,
437 channels: vec![AnimChannel {
438 node: 2,
439 property: ChannelProperty::Rotation,
440 times: vec![0., 0.5, 1.],
441 values: vec![0., 0., 0., 1., half.0, 0., 0., half.1, 0., 0., 0., 1.],
442 interpolation: Interpolation::Linear,
443 }],
444 skipped: Vec::new(),
445 };
446 PreviewCharacter {
447 nodes,
448 joint_nodes: (0..5).collect(),
449 joint_names: ["root", "thigh", "calf", "footControl", "toes"]
450 .map(String::from)
451 .to_vec(),
452 inverse_binds: vec![preview::identity(); 5],
453 positions: vec![[0., 0., 0.], [0., 1.8, 0.]],
454 normals: vec![[0., 1., 0.]; 2],
455 source_normals_complete: true,
456 joints: vec![[0; 4]; 2],
457 weights: vec![[1., 0., 0., 0.]; 2],
458 indices: Vec::new(),
459 base_color: [1.; 4],
460 colors: Vec::new(),
461 uvs: Vec::new(),
462 surfaces: Vec::new(),
463 clips: vec![clip],
464 rig: BodyRig::Skinned,
465 fixups: BodyFixups::default(),
466 }
467 }
468
469 #[test]
470 fn detached_foot_follows_the_shin() {
471 let mut leg = ik_leg(-40.);
472 leg.fixups = detect(&leg);
473 assert_eq!(leg.fixups.follow, vec![(3, 2)]);
474 assert!(!leg.fixups.mirror_motion && !leg.fixups.turn_180);
475 let w = preview::posed_worlds(&leg, "walk", 0.5);
476 // Foot origin = shin end (0.45 m along the shin), toes keep their offset.
477 let end = point(&w[2], [0., 0.45, 0.]);
478 for a in 0..3 {
479 assert!(
480 (w[3][12 + a] - end[a]).abs() < 1e-4,
481 "{:?} vs {end:?}",
482 &w[3][12..15]
483 );
484 }
485 assert!(
486 end[2] > 0.2,
487 "shin swings back (+Z) for a −Z front: {end:?}"
488 );
489 let toes = [w[4][12], w[4][13], w[4][14]];
490 assert!((toes[2] - end[2] + 0.1).abs() < 0.05 || (toes[1] - end[1]).abs() > 0.01);
491 // At rest nothing moves.
492 let r = preview::posed_worlds(&leg, "", 0.);
493 assert!((r[3][13] - 0.05).abs() < 1e-5);
494 }
495
496 #[test]
497 fn backwards_knees_mirror_the_motion_front_back() {
498 let mut leg = ik_leg(40.);
499 leg.fixups = detect(&leg);
500 assert!(leg.fixups.mirror_motion, "{:?}", leg.fixups.notes);
501 let w = preview::posed_worlds(&leg, "walk", 0.5);
502 let ankle = [w[3][12], w[3][13], w[3][14]];
503 assert!(
504 ankle[2] > 0.2,
505 "after the mirror the shin swings back: {ankle:?}"
506 );
507 // The rest pose is unchanged by the mirror.
508 let r = preview::posed_worlds(&leg, "", 0.);
509 assert!((r[2][13] - 0.5).abs() < 1e-5 && r[2][14].abs() < 1e-5);
510 }
511
512 #[test]
513 fn plus_z_bodies_turn_and_test_bodies_stay() {
514 let mut feet = Vec::new();
515 for i in 0..1200 {
516 let f = i as f32 / 1200.;
517 // Feet (lowest 4%) reach +Z, ankles (6–10% of height) sit behind.
518 if i % 3 == 0 {
519 feet.push([f * 0.1, 0.02, 0.1 * f]);
520 } else if i % 3 == 1 {
521 feet.push([f * 0.1, 0.16, -0.05]);
522 } else {
523 feet.push([f * 0.1, 0.2 + 1.5 * f, 0.]);
524 }
525 }
526 assert_eq!(detect_forward(&feet), Some(Forward::PlusZ));
527 let mirrored: Vec<[f32; 3]> = feet.iter().map(|p| [p[0], p[1], -p[2]]).collect();
528 assert_eq!(detect_forward(&mirrored), Some(Forward::MinusZ));
529 for f in [
530 "test-body-male.glb",
531 "test-body-female.glb",
532 "test-body-male.fbx",
533 ] {
534 let b = preview::load_body_file(
535 &std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
536 .join("tests/fixtures/bodies")
537 .join(f),
538 )
539 .unwrap();
540 assert!(b.fixups.is_identity(), "{f}: {:?}", b.fixups.notes);
541 }
542 }
543 }
@@ -0,0 +1,543 @@
1//! Load-time orientation and animation corrections for source bodies.
2//!
3//! Applied once per body after loading, inside `preview::posed_worlds`, so
4//! the live view, stills, production and equipment all see the same pose.
5//!
6//! * **Forward axis.** The renderer's convention is "character faces −Z"
7//! (yaw 0 = front). A body whose feet point to +Z (toes in front of the
8//! ankles, the glTF default) is turned 180° about Y.
9//! * **Detached feet.** IK rigs often keep the foot bones under the root
10//! instead of under the shin. When such a foot chain starts exactly at the
11//! end of an animated leaf bone and has no animation of its own, it follows
12//! that bone rigidly (as forward kinematics would).
13//! * **Front/back mirrored motion.** When knees bend the wrong way relative
14//! to the body's own front in almost every frame, the motion is mirrored
15//! front/back (per-joint world deltas reflected through the frontal plane);
16//! the rest pose is unchanged.
17use crate::preview::{self, affine_inverse, mul, Mat4, PreviewCharacter};
18
19#[derive(Debug, Clone, Default, PartialEq)]
20pub struct BodyFixups {
21 /// Turn the whole body 180° about +Y (source faces +Z).
22 pub turn_180: bool,
23 /// Reflect the motion front/back (source-local Z) around the rest pose.
24 pub mirror_motion: bool,
25 /// (node, driver node): node's subtree follows the driver rigidly.
26 pub follow: Vec<(usize, usize)>,
27 /// Human-readable (Dutch) explanations for the editor/render log.
28 pub notes: Vec<String>,
29}
30
31impl BodyFixups {
32 pub fn is_identity(&self) -> bool {
33 !self.turn_180 && !self.mirror_motion && self.follow.is_empty()
34 }
35}
36
37/// Which way a body faces, as decided by `detect`.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Forward {
40 MinusZ,
41 PlusZ,
42}
43
44fn diag(x: f32, y: f32, z: f32) -> Mat4 {
45 let mut m = preview::identity();
46 m[0] = x;
47 m[5] = y;
48 m[10] = z;
49 m
50}
51
52fn descendants(character: &PreviewCharacter, root: usize) -> Vec<usize> {
53 let mut out = vec![root];
54 let mut i = 0;
55 while i < out.len() {
56 let n = out[i];
57 for (c, node) in character.nodes.iter().enumerate() {
58 if node.parent == Some(n) && !out.contains(&c) {
59 out.push(c);
60 }
61 }
62 i += 1;
63 }
64 out
65}
66
67fn is_ancestor(character: &PreviewCharacter, a: usize, mut n: usize) -> bool {
68 let mut guard = 0;
69 while let Some(p) = character.nodes.get(n).and_then(|x| x.parent) {
70 if p == a {
71 return true;
72 }
73 n = p;
74 guard += 1;
75 if guard > character.nodes.len() {
76 break;
77 }
78 }
79 false
80}
81
82/// Apply `character.fixups` to raw posed world matrices.
83pub fn apply(character: &PreviewCharacter, worlds: Vec<Mat4>) -> Vec<Mat4> {
84 apply_with(character, &character.fixups, worlds)
85}
86
87fn apply_with(character: &PreviewCharacter, f: &BodyFixups, mut w: Vec<Mat4>) -> Vec<Mat4> {
88 if f.is_identity() {
89 return w;
90 }
91 let rest = preview::rest_worlds(character);
92 for &(node, driver) in &f.follow {
93 let (Some(rd), Some(rn)) = (affine_inverse(&rest[driver]), affine_inverse(&w[node])) else {
94 continue;
95 };
96 let delta = mul(w[driver], rd);
97 let new_node = mul(delta, rest[node]);
98 for d in descendants(character, node) {
99 w[d] = mul(new_node, mul(rn, w[d]));
100 }
101 }
102 if f.mirror_motion {
103 let s = diag(1., 1., -1.);
104 for (i, m) in w.iter_mut().enumerate() {
105 if let Some(ri) = affine_inverse(&rest[i]) {
106 *m = mul(s, mul(*m, mul(ri, mul(s, rest[i]))));
107 }
108 }
109 }
110 if f.turn_180 {
111 let r = diag(-1., 1., -1.);
112 for m in w.iter_mut() {
113 *m = mul(r, *m);
114 }
115 }
116 w
117}
118
119fn point(m: &Mat4, p: [f32; 3]) -> [f32; 3] {
120 [
121 m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
122 m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
123 m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14],
124 ]
125}
126
127/// Rest-pose vertex positions (bind positions through the rest skin).
128fn rest_positions(character: &PreviewCharacter) -> Vec<[f32; 3]> {
129 if character.is_static() || character.joint_nodes.is_empty() {
130 return character.positions.clone();
131 }
132 let rest = preview::rest_worlds(character);
133 let skin: Vec<Mat4> = character
134 .joint_nodes
135 .iter()
136 .zip(&character.inverse_binds)
137 .map(|(n, ib)| mul(rest[*n], *ib))
138 .collect();
139 character
140 .positions
141 .iter()
142 .enumerate()
143 .map(|(i, p)| {
144 let w = character
145 .weights
146 .get(i)
147 .copied()
148 .unwrap_or([1., 0., 0., 0.]);
149 let j = character.joints.get(i).copied().unwrap_or([0; 4]);
150 let mut out = [0.; 3];
151 let mut sum = 0.;
152 for k in 0..4 {
153 if w[k] > 0. {
154 if let Some(m) = skin.get(j[k] as usize) {
155 let q = point(m, *p);
156 for a in 0..3 {
157 out[a] += w[k] * q[a];
158 }
159 sum += w[k];
160 }
161 }
162 }
163 if sum > 0. {
164 out.map(|v| v / sum)
165 } else {
166 *p
167 }
168 })
169 .collect()
170}
171
172/// Feet heuristic: the vertices of the lowest 4% of the body (feet) extend
173/// further forward than those at 6–10% (ankles/shins). None when unclear
174/// (meshes under 1000 vertices such as box proxies, fewer than 12 vertices per
175/// band, or a difference under 1% of height).
176pub fn detect_forward(positions: &[[f32; 3]]) -> Option<Forward> {
177 let (lo, hi) = positions
178 .iter()
179 .fold((f32::INFINITY, f32::NEG_INFINITY), |(a, b), p| {
180 (a.min(p[1]), b.max(p[1]))
181 });
182 let h = hi - lo;
183 if !(h.is_finite() && h > 0.) || positions.len() < 1000 {
184 return None;
185 }
186 let mean = |a: f32, b: f32| {
187 let v: Vec<f32> = positions
188 .iter()
189 .filter(|p| p[1] >= lo + a * h && p[1] <= lo + b * h)
190 .map(|p| p[2])
191 .collect();
192 // Too few vertices (box-like proxies) give no reliable feet shape.
193 (v.len() >= 12).then(|| v.iter().sum::<f32>() / v.len() as f32)
194 };
195 let feet = mean(0., 0.04)?;
196 let ankles = mean(0.06, 0.10)?;
197 let d = feet - ankles;
198 if d > 0.01 * h {
199 Some(Forward::PlusZ)
200 } else if d < -0.01 * h {
201 Some(Forward::MinusZ)
202 } else {
203 None
204 }
205}
206
207fn channel_static(ch: &preview::AnimChannel) -> bool {
208 let n = ch.property.width();
209 let first: Vec<f32> = ch.values.iter().take(n).copied().collect();
210 ch.values
211 .chunks(n.max(1))
212 .all(|k| k.iter().zip(&first).all(|(a, b)| (a - b).abs() < 1e-5))
213}
214
215fn node_animated(character: &PreviewCharacter, node: usize) -> bool {
216 character.clips.iter().any(|c| {
217 c.channels
218 .iter()
219 .any(|ch| ch.node == node && !channel_static(ch))
220 })
221}
222
223fn node_matches_rest(character: &PreviewCharacter, node: usize) -> bool {
224 // Static channels must also equal the rest pose (else the bone is posed).
225 let n = &character.nodes[node];
226 character.clips.iter().all(|c| {
227 c.channels.iter().filter(|ch| ch.node == node).all(|ch| {
228 let rest: Vec<f32> = match ch.property {
229 preview::ChannelProperty::Translation => n.translation.to_vec(),
230 preview::ChannelProperty::Rotation => n.rotation.to_vec(),
231 preview::ChannelProperty::Scale => n.scale.to_vec(),
232 };
233 let k = &ch.values[..rest.len().min(ch.values.len())];
234 let dot: f32 = k.iter().zip(&rest).map(|(a, b)| a * b).sum();
235 match ch.property {
236 preview::ChannelProperty::Rotation => dot.abs() > 0.9999,
237 _ => k.iter().zip(&rest).all(|(a, b)| (a - b).abs() < 1e-4),
238 }
239 })
240 })
241}
242
243/// Detect all fixups for a freshly loaded body.
244pub fn detect(character: &PreviewCharacter) -> BodyFixups {
245 let mut f = BodyFixups::default();
246 let positions = rest_positions(character);
247 let forward = detect_forward(&positions);
248 if character.is_static() {
249 return f;
250 }
251 let rest = preview::rest_worlds(character);
252 let (lo, hi) = positions
253 .iter()
254 .fold((f32::INFINITY, f32::NEG_INFINITY), |(a, b), p| {
255 (a.min(p[1]), b.max(p[1]))
256 });
257 let h = (hi - lo).max(1e-3);
258 let joints = &character.joint_nodes;
259 let has_joint_child = |n: usize| joints.iter().any(|c| character.nodes[*c].parent == Some(n));
260 // Detached feet.
261 for &driver in joints {
262 if has_joint_child(driver) || !node_animated(character, driver) {
263 continue;
264 }
265 let Some(inv) = affine_inverse(&rest[driver]) else {
266 continue;
267 };
268 for &cand in joints {
269 if cand == driver
270 || is_ancestor(character, cand, driver)
271 || is_ancestor(character, driver, cand)
272 {
273 continue;
274 }
275 let o = [rest[cand][12], rest[cand][13], rest[cand][14]];
276 let l = point(&inv, o);
277 // Rest origin on the driver's bone axis (+Y), beyond its head.
278 let len = (rest[driver][4].powi(2) + rest[driver][5].powi(2) + rest[driver][6].powi(2))
279 .sqrt();
280 let along = l[1] * len;
281 if !(l[0].abs() * len < 0.003 * h
282 && l[2].abs() * len < 0.003 * h
283 && along > 0.05 * h
284 && along < 0.5 * h)
285 {
286 continue;
287 }
288 // Climb to the topmost ancestor that is not above the driver.
289 let mut top = cand;
290 while let Some(p) = character.nodes[top].parent {
291 if is_ancestor(character, p, driver) || p == driver {
292 break;
293 }
294 top = p;
295 }
296 let subtree = descendants(character, top);
297 if subtree
298 .iter()
299 .any(|n| node_animated(character, *n) || !node_matches_rest(character, *n))
300 {
301 continue;
302 }
303 if !f.follow.iter().any(|(n, _)| *n == top) {
304 f.follow.push((top, driver));
305 f.notes.push(format!(
306 "{} volgt {} (losse voetketen zonder eigen animatie)",
307 character.nodes[top].name, character.nodes[driver].name
308 ));
309 }
310 }
311 }
312 // Knee direction vs. the body's own front.
313 let front = match forward {
314 Some(Forward::PlusZ) => 1.,
315 _ => -1.,
316 };
317 let mut legs: Vec<(usize, usize, [f32; 3])> = Vec::new(); // (thigh node, shin node, ankle in shin space)
318 for &(node, driver) in &f.follow {
319 if let (Some(p), Some(inv)) = (
320 character.nodes[driver].parent,
321 affine_inverse(&rest[driver]),
322 ) {
323 legs.push((
324 p,
325 driver,
326 point(&inv, [rest[node][12], rest[node][13], rest[node][14]]),
327 ));
328 }
329 }
330 let (mut good, mut bad) = (0, 0);
331 if !legs.is_empty() {
332 let partial = BodyFixups {
333 follow: f.follow.clone(),
334 ..Default::default()
335 };
336 for clip in &character.clips {
337 let frames = ((clip.duration * 24.).ceil() as usize).max(1);
338 for i in 0..frames {
339 let w = apply_with(
340 character,
341 &partial,
342 preview::raw_posed_worlds(character, &clip.name, i as f32 / 24.),
343 );
344 for (thigh, shin, ankle_local) in &legs {
345 let hip = [w[*thigh][12], w[*thigh][13], w[*thigh][14]];
346 let knee = [w[*shin][12], w[*shin][13], w[*shin][14]];
347 let ankle = point(&w[*shin], *ankle_local);
348 let dy = ankle[1] - hip[1];
349 if dy.abs() < 1e-4 {
350 continue;
351 }
352 let t = (knee[1] - hip[1]) / dy;
353 let line_z = hip[2] + t * (ankle[2] - hip[2]);
354 let off = (knee[2] - line_z) * front;
355 if off > 0.02 * h {
356 good += 1;
357 } else if off < -0.02 * h {
358 bad += 1;
359 }
360 }
361 }
362 }
363 }
364 if bad >= 4 && bad > 3 * good {
365 f.mirror_motion = true;
366 f.notes.push(format!(
367 "beweging voor/achter gespiegeld: knieën bogen in {bad} van {} gemeten standen de verkeerde kant op",
368 bad + good
369 ));
370 }
371 if forward == Some(Forward::PlusZ) {
372 f.turn_180 = true;
373 f.notes.push("lichaam kijkt naar +Z (tenen voor de enkels): 180° gedraaid zodat yaw 0 de voorkant toont".into());
374 }
375 f
376}
377
378/// Detect and store the fixups on a loaded body. Static bodies (no bones)
379/// get their +Z-facing geometry turned directly.
380pub fn finalize(mut character: PreviewCharacter) -> PreviewCharacter {
381 if character.is_static() {
382 if detect_forward(&character.positions) == Some(Forward::PlusZ) {
383 for p in character
384 .positions
385 .iter_mut()
386 .chain(character.normals.iter_mut())
387 {
388 p[0] = -p[0];
389 p[2] = -p[2];
390 }
391 character.fixups.notes.push(
392 "lichaam kijkt naar +Z (tenen voor de enkels): 180° gedraaid zodat yaw 0 de voorkant toont".into(),
393 );
394 }
395 return character;
396 }
397 character.fixups = detect(&character);
398 character
399}
400
401/// Forward axis of a body's rest pose (None when unclear).
402pub fn forward_of(character: &PreviewCharacter) -> Option<Forward> {
403 detect_forward(&rest_positions(character))
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use crate::preview::{
410 AnimChannel, BodyRig, ChannelProperty, Interpolation, PreviewClip, SceneNode,
411 };
412
413 fn node(name: &str, parent: Option<usize>, t: [f32; 3], r: [f32; 4]) -> SceneNode {
414 SceneNode {
415 name: name.into(),
416 parent,
417 translation: t,
418 rotation: r,
419 scale: [1.; 3],
420 }
421 }
422
423 /// Leg whose foot control hangs under the root (IK style), with a knee
424 /// that bends by `knee_deg` (negative = shin back = natural for −Z front).
425 fn ik_leg(knee_deg: f32) -> PreviewCharacter {
426 let half = (knee_deg.to_radians() / 2.).sin_cos();
427 let nodes = vec![
428 node("root", None, [0., 0., 0.], [0., 0., 0., 1.]),
429 node("thigh", Some(0), [0., 1., 0.], [1., 0., 0., 0.]),
430 node("calf", Some(1), [0., 0.5, 0.], [0., 0., 0., 1.]),
431 node("footControl", Some(0), [0., 0.05, 0.], [0., 0., 0., 1.]),
432 node("toes", Some(3), [0., 0., -0.1], [0., 0., 0., 1.]),
433 ];
434 let clip = PreviewClip {
435 name: "walk".into(),
436 duration: 1.,
437 channels: vec![AnimChannel {
438 node: 2,
439 property: ChannelProperty::Rotation,
440 times: vec![0., 0.5, 1.],
441 values: vec![0., 0., 0., 1., half.0, 0., 0., half.1, 0., 0., 0., 1.],
442 interpolation: Interpolation::Linear,
443 }],
444 skipped: Vec::new(),
445 };
446 PreviewCharacter {
447 nodes,
448 joint_nodes: (0..5).collect(),
449 joint_names: ["root", "thigh", "calf", "footControl", "toes"]
450 .map(String::from)
451 .to_vec(),
452 inverse_binds: vec![preview::identity(); 5],
453 positions: vec![[0., 0., 0.], [0., 1.8, 0.]],
454 normals: vec![[0., 1., 0.]; 2],
455 source_normals_complete: true,
456 joints: vec![[0; 4]; 2],
457 weights: vec![[1., 0., 0., 0.]; 2],
458 indices: Vec::new(),
459 base_color: [1.; 4],
460 colors: Vec::new(),
461 uvs: Vec::new(),
462 surfaces: Vec::new(),
463 clips: vec![clip],
464 rig: BodyRig::Skinned,
465 fixups: BodyFixups::default(),
466 }
467 }
468
469 #[test]
470 fn detached_foot_follows_the_shin() {
471 let mut leg = ik_leg(-40.);
472 leg.fixups = detect(&leg);
473 assert_eq!(leg.fixups.follow, vec![(3, 2)]);
474 assert!(!leg.fixups.mirror_motion && !leg.fixups.turn_180);
475 let w = preview::posed_worlds(&leg, "walk", 0.5);
476 // Foot origin = shin end (0.45 m along the shin), toes keep their offset.
477 let end = point(&w[2], [0., 0.45, 0.]);
478 for a in 0..3 {
479 assert!(
480 (w[3][12 + a] - end[a]).abs() < 1e-4,
481 "{:?} vs {end:?}",
482 &w[3][12..15]
483 );
484 }
485 assert!(
486 end[2] > 0.2,
487 "shin swings back (+Z) for a −Z front: {end:?}"
488 );
489 let toes = [w[4][12], w[4][13], w[4][14]];
490 assert!((toes[2] - end[2] + 0.1).abs() < 0.05 || (toes[1] - end[1]).abs() > 0.01);
491 // At rest nothing moves.
492 let r = preview::posed_worlds(&leg, "", 0.);
493 assert!((r[3][13] - 0.05).abs() < 1e-5);
494 }
495
496 #[test]
497 fn backwards_knees_mirror_the_motion_front_back() {
498 let mut leg = ik_leg(40.);
499 leg.fixups = detect(&leg);
500 assert!(leg.fixups.mirror_motion, "{:?}", leg.fixups.notes);
501 let w = preview::posed_worlds(&leg, "walk", 0.5);
502 let ankle = [w[3][12], w[3][13], w[3][14]];
503 assert!(
504 ankle[2] > 0.2,
505 "after the mirror the shin swings back: {ankle:?}"
506 );
507 // The rest pose is unchanged by the mirror.
508 let r = preview::posed_worlds(&leg, "", 0.);
509 assert!((r[2][13] - 0.5).abs() < 1e-5 && r[2][14].abs() < 1e-5);
510 }
511
512 #[test]
513 fn plus_z_bodies_turn_and_test_bodies_stay() {
514 let mut feet = Vec::new();
515 for i in 0..1200 {
516 let f = i as f32 / 1200.;
517 // Feet (lowest 4%) reach +Z, ankles (6–10% of height) sit behind.
518 if i % 3 == 0 {
519 feet.push([f * 0.1, 0.02, 0.1 * f]);
520 } else if i % 3 == 1 {
521 feet.push([f * 0.1, 0.16, -0.05]);
522 } else {
523 feet.push([f * 0.1, 0.2 + 1.5 * f, 0.]);
524 }
525 }
526 assert_eq!(detect_forward(&feet), Some(Forward::PlusZ));
527 let mirrored: Vec<[f32; 3]> = feet.iter().map(|p| [p[0], p[1], -p[2]]).collect();
528 assert_eq!(detect_forward(&mirrored), Some(Forward::MinusZ));
529 for f in [
530 "test-body-male.glb",
531 "test-body-female.glb",
532 "test-body-male.fbx",
533 ] {
534 let b = preview::load_body_file(
535 &std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
536 .join("tests/fixtures/bodies")
537 .join(f),
538 )
539 .unwrap();
540 assert!(b.fixups.is_identity(), "{f}: {:?}", b.fixups.notes);
541 }
542 }
543}
src/preview.rs +41 −6
@@ -103,6 +103,8 @@ pub struct PreviewCharacter {
103 103 pub clips: Vec<PreviewClip>,
104 104 /// Skinned (skeleton + clips) or static geometry without a skeleton.
105 105 pub rig: BodyRig,
106 /// Load-time orientation/animation corrections (see `orient`).
107 pub fixups: crate::orient::BodyFixups,
106 108 }
107 109
108 110 /// Whether a body has a real skin. A static body keeps an empty skeleton and
@@ -309,6 +311,11 @@ pub fn is_drawable_body(path: &Path) -> bool {
309 311 /// deformer) load as [`BodyRig::Static`]: geometry in its stored pose, no
310 312 /// skeleton and no clips.
311 313 pub fn load_body_file(path: &Path) -> Result<PreviewCharacter, String> {
314 load_body_file_raw(path).map(crate::orient::finalize)
315 }
316
317 /// Load without orientation/animation fixups (diagnostics and tests).
318 pub fn load_body_file_raw(path: &Path) -> Result<PreviewCharacter, String> {
312 319 match path
313 320 .extension()
314 321 .and_then(|e| e.to_str())
@@ -486,6 +493,7 @@ impl StaticAcc {
486 493 format: format.to_string(),
487 494 reason,
488 495 },
496 fixups: Default::default(),
489 497 })
490 498 }
491 499 }
@@ -771,6 +779,7 @@ fn load_gltf_file_unguarded(path: &Path) -> Result<PreviewCharacter, String> {
771 779 surfaces,
772 780 clips,
773 781 rig: BodyRig::Skinned,
782 fixups: Default::default(),
774 783 })
775 784 }
776 785
@@ -1022,7 +1031,7 @@ fn apply_uv(m: &[[f32; 3]; 2], uv: [f32; 2]) -> [f32; 2] {
1022 1031 }
1023 1032
1024 1033 /// Inverse of an affine matrix (None when singular).
1025 fn affine_inverse(m: &Mat4) -> Option<Mat4> {
1034 pub(crate) fn affine_inverse(m: &Mat4) -> Option<Mat4> {
1026 1035 let it = normal_matrix(m)?; // inverse-transpose of the 3×3
1027 1036 // inverse 3×3 (row r, col c) = it[c][r]
1028 1037 let mut out = identity();
@@ -1088,9 +1097,37 @@ fn mat3_mul(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
1088 1097 ]
1089 1098 }
1090 1099
1091 pub fn sample_skin_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {
1100 /// World matrices of every node for a clip time, with the body's load-time
1101 /// fixups applied (feet following the shin, motion mirror, 180° turn). Every
1102 /// render path (live, still, production, equipment) goes through here.
1103 pub fn posed_worlds(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {
1092 1104 let locals = sample_local_matrices(character, clip_name, time);
1093 1105 let worlds = world_matrices(&character.nodes, &locals);
1106 crate::orient::apply(character, worlds)
1107 }
1108
1109 pub(crate) fn rest_worlds(character: &PreviewCharacter) -> Vec<Mat4> {
1110 let locals: Vec<Mat4> = character
1111 .nodes
1112 .iter()
1113 .map(|n| trs(n.translation, n.rotation, n.scale))
1114 .collect();
1115 world_matrices(&character.nodes, &locals)
1116 }
1117
1118 pub(crate) fn raw_posed_worlds(
1119 character: &PreviewCharacter,
1120 clip_name: &str,
1121 time: f32,
1122 ) -> Vec<Mat4> {
1123 world_matrices(
1124 &character.nodes,
1125 &sample_local_matrices(character, clip_name, time),
1126 )
1127 }
1128
1129 pub fn sample_skin_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {
1130 let worlds = posed_worlds(character, clip_name, time);
1094 1131 character
1095 1132 .joint_nodes
1096 1133 .iter()
@@ -1148,8 +1185,7 @@ pub fn animated_joint_world(
1148 1185 time: f32,
1149 1186 joint: usize,
1150 1187 ) -> Option<Mat4> {
1151 let locals = sample_local_matrices(character, clip, time);
1152 let worlds = world_matrices(&character.nodes, &locals);
1188 let worlds = posed_worlds(character, clip, time);
1153 1189 character
1154 1190 .joint_nodes
1155 1191 .get(joint)
@@ -1168,8 +1204,7 @@ pub fn joint_world_translation(
1168 1204 .iter()
1169 1205 .position(|name| name == joint_name)?;
1170 1206 let node = character.joint_nodes[joint];
1171 let locals = sample_local_matrices(character, clip_name, time);
1172 let worlds = world_matrices(&character.nodes, &locals);
1207 let worlds = posed_worlds(character, clip_name, time);
1173 1208 Some([worlds[node][12], worlds[node][13], worlds[node][14]])
1174 1209 }
1175 1210
@@ -103,6 +103,8 @@ pub struct PreviewCharacter {
103 pub clips: Vec<PreviewClip>,
103 pub clips: Vec<PreviewClip>,
104 /// Skinned (skeleton + clips) or static geometry without a skeleton.
104 /// Skinned (skeleton + clips) or static geometry without a skeleton.
105 pub rig: BodyRig,
105 pub rig: BodyRig,
106 /// Load-time orientation/animation corrections (see `orient`).
107 pub fixups: crate::orient::BodyFixups,
106}
108}
107
109
108/// Whether a body has a real skin. A static body keeps an empty skeleton and
110/// Whether a body has a real skin. A static body keeps an empty skeleton and
@@ -309,6 +311,11 @@ pub fn is_drawable_body(path: &Path) -> bool {
309/// deformer) load as [`BodyRig::Static`]: geometry in its stored pose, no
311/// deformer) load as [`BodyRig::Static`]: geometry in its stored pose, no
310/// skeleton and no clips.
312/// skeleton and no clips.
311pub fn load_body_file(path: &Path) -> Result<PreviewCharacter, String> {
313pub fn load_body_file(path: &Path) -> Result<PreviewCharacter, String> {
314 load_body_file_raw(path).map(crate::orient::finalize)
315}
316
317/// Load without orientation/animation fixups (diagnostics and tests).
318pub fn load_body_file_raw(path: &Path) -> Result<PreviewCharacter, String> {
312 match path
319 match path
313 .extension()
320 .extension()
314 .and_then(|e| e.to_str())
321 .and_then(|e| e.to_str())
@@ -486,6 +493,7 @@ impl StaticAcc {
486 format: format.to_string(),
493 format: format.to_string(),
487 reason,
494 reason,
488 },
495 },
496 fixups: Default::default(),
489 })
497 })
490 }
498 }
491}
499}
@@ -771,6 +779,7 @@ fn load_gltf_file_unguarded(path: &Path) -> Result<PreviewCharacter, String> {
771 surfaces,
779 surfaces,
772 clips,
780 clips,
773 rig: BodyRig::Skinned,
781 rig: BodyRig::Skinned,
782 fixups: Default::default(),
774 })
783 })
775}
784}
776
785
@@ -1022,7 +1031,7 @@ fn apply_uv(m: &[[f32; 3]; 2], uv: [f32; 2]) -> [f32; 2] {
1022}
1031}
1023
1032
1024/// Inverse of an affine matrix (None when singular).
1033/// Inverse of an affine matrix (None when singular).
1025fn affine_inverse(m: &Mat4) -> Option<Mat4> {
1034pub(crate) fn affine_inverse(m: &Mat4) -> Option<Mat4> {
1026 let it = normal_matrix(m)?; // inverse-transpose of the 3×3
1035 let it = normal_matrix(m)?; // inverse-transpose of the 3×3
1027 // inverse 3×3 (row r, col c) = it[c][r]
1036 // inverse 3×3 (row r, col c) = it[c][r]
1028 let mut out = identity();
1037 let mut out = identity();
@@ -1088,9 +1097,37 @@ fn mat3_mul(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
1088 ]
1097 ]
1089}
1098}
1090
1099
1091pub fn sample_skin_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {
1100/// World matrices of every node for a clip time, with the body's load-time
1101/// fixups applied (feet following the shin, motion mirror, 180° turn). Every
1102/// render path (live, still, production, equipment) goes through here.
1103pub fn posed_worlds(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {
1092 let locals = sample_local_matrices(character, clip_name, time);
1104 let locals = sample_local_matrices(character, clip_name, time);
1093 let worlds = world_matrices(&character.nodes, &locals);
1105 let worlds = world_matrices(&character.nodes, &locals);
1106 crate::orient::apply(character, worlds)
1107}
1108
1109pub(crate) fn rest_worlds(character: &PreviewCharacter) -> Vec<Mat4> {
1110 let locals: Vec<Mat4> = character
1111 .nodes
1112 .iter()
1113 .map(|n| trs(n.translation, n.rotation, n.scale))
1114 .collect();
1115 world_matrices(&character.nodes, &locals)
1116}
1117
1118pub(crate) fn raw_posed_worlds(
1119 character: &PreviewCharacter,
1120 clip_name: &str,
1121 time: f32,
1122) -> Vec<Mat4> {
1123 world_matrices(
1124 &character.nodes,
1125 &sample_local_matrices(character, clip_name, time),
1126 )
1127}
1128
1129pub fn sample_skin_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {
1130 let worlds = posed_worlds(character, clip_name, time);
1094 character
1131 character
1095 .joint_nodes
1132 .joint_nodes
1096 .iter()
1133 .iter()
@@ -1148,8 +1185,7 @@ pub fn animated_joint_world(
1148 time: f32,
1185 time: f32,
1149 joint: usize,
1186 joint: usize,
1150) -> Option<Mat4> {
1187) -> Option<Mat4> {
1151 let locals = sample_local_matrices(character, clip, time);
1188 let worlds = posed_worlds(character, clip, time);
1152 let worlds = world_matrices(&character.nodes, &locals);
1153 character
1189 character
1154 .joint_nodes
1190 .joint_nodes
1155 .get(joint)
1191 .get(joint)
@@ -1168,8 +1204,7 @@ pub fn joint_world_translation(
1168 .iter()
1204 .iter()
1169 .position(|name| name == joint_name)?;
1205 .position(|name| name == joint_name)?;
1170 let node = character.joint_nodes[joint];
1206 let node = character.joint_nodes[joint];
1171 let locals = sample_local_matrices(character, clip_name, time);
1207 let worlds = posed_worlds(character, clip_name, time);
1172 let worlds = world_matrices(&character.nodes, &locals);
1173 Some([worlds[node][12], worlds[node][13], worlds[node][14]])
1208 Some([worlds[node][12], worlds[node][13], worlds[node][14]])
1174}
1209}
1175
1210