Merge feature/anim-orientation-fixups (Tim t965u animation/orientation fixes)
@@ -30,6 +30,14 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert
30
30
31
31

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** selecteert30
30
31
31
32
32
33
### Automatische correcties bij het laden34
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
### Meerdere meshes en skins (glTF/GLB)41
### Meerdere meshes en skins (glTF/GLB)34
42
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. `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).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).@@ -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
@@ -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);@@ -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,@@ -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;23
pub mod foundation;23
pub mod foundation;24
pub mod icon;24
pub mod icon;25
pub mod import;25
pub mod import;26
pub mod orient;26
pub mod preview;27
pub mod preview;27
pub mod preview2d;28
pub mod preview2d;28
pub mod preview_gl;29
pub mod preview_gl;@@ -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`, so4
//! 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 the8
//! ankles, the glTF default) is turned 180° about Y.9
//! * **Detached feet.** IK rigs often keep the foot bones under the root10
//! instead of under the shin. When such a foot chain starts exactly at the11
//! end of an animated leaf bone and has no animation of its own, it follows12
//! that bone rigidly (as forward kinematics would).13
//! * **Front/back mirrored motion.** When knees bend the wrong way relative14
//! to the body's own front in almost every frame, the motion is mirrored15
//! 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
m50
}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
out65
}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
false80
}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
w117
}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> = character134
.joint_nodes135
.iter()136
.zip(&character.inverse_binds)137
.map(|(n, ib)| mul(rest[*n], *ib))138
.collect();139
character140
.positions141
.iter()142
.enumerate()143
.map(|(i, p)| {144
let w = character145
.weights146
.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
*p167
}168
})169
.collect()170
}171
172
/// Feet heuristic: the vertices of the lowest 4% of the body (feet) extend173
/// further forward than those at 6–10% (ankles/shins). None when unclear174
/// (meshes under 1000 vertices such as box proxies, fewer than 12 vertices per175
/// band, or a difference under 1% of height).176
pub fn detect_forward(positions: &[[f32; 3]]) -> Option<Forward> {177
let (lo, hi) = positions178
.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> = positions188
.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
None204
}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.values211
.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.channels218
.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) = positions253
.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 == driver270
|| 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 * h282
&& l[2].abs() * len < 0.003 * h283
&& along > 0.05 * h284
&& 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 subtree298
.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].name308
));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 + good369
));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
f376
}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 character384
.positions385
.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
character399
}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 knee424
/// 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
}@@ -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 and110
/// 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, no311
/// deformer) load as [`BodyRig::Static`]: geometry in its stored pose, no310
/// skeleton and no clips.312
/// skeleton and no clips.311
pub fn load_body_file(path: &Path) -> Result<PreviewCharacter, String> {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
match path319
match path313
.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).1025
fn affine_inverse(m: &Mat4) -> Option<Mat4> {1034
pub(crate) fn affine_inverse(m: &Mat4) -> Option<Mat4> {1026
let it = normal_matrix(m)?; // inverse-transpose of the 3×31035
let it = normal_matrix(m)?; // inverse-transpose of the 3×31027
// 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
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-time1101
/// fixups applied (feet following the shin, motion mirror, 180° turn). Every1102
/// render path (live, still, production, equipment) goes through here.1103
pub 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
1109
pub(crate) fn rest_worlds(character: &PreviewCharacter) -> Vec<Mat4> {1110
let locals: Vec<Mat4> = character1111
.nodes1112
.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
character1131
character1095
.joint_nodes1132
.joint_nodes1096
.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
character1189
character1154
.joint_nodes1190
.joint_nodes1155
.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