Skip to main content

oxihuman_morph/
expression.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Facial expression preset system.
5//!
6//! An expression preset is a named combination of expression target weights.
7//! These map to MakeHuman expression target filenames under the `expression/` subdirectory.
8//! Actual target stems are drawn from `expression/units/{ethnicity}/` files.
9
10use crate::engine::HumanEngine;
11use crate::params::ParamState;
12
13/// A single component of an expression: a target filename stem and its weight.
14#[derive(Debug, Clone)]
15pub struct ExpressionComponent {
16    /// Target filename stem (without .target extension), e.g. "mouth-open"
17    pub target_name: String,
18    /// Blend weight for this component (0.0..=1.0)
19    pub weight: f32,
20}
21
22impl ExpressionComponent {
23    fn new(target_name: &str, weight: f32) -> Self {
24        Self {
25            target_name: target_name.to_string(),
26            weight,
27        }
28    }
29}
30
31/// A named facial expression preset composed of multiple target components.
32#[derive(Debug, Clone)]
33pub struct ExpressionPreset {
34    pub name: String,
35    pub components: Vec<ExpressionComponent>,
36}
37
38impl ExpressionPreset {
39    fn new(name: &str, components: Vec<ExpressionComponent>) -> Self {
40        Self {
41            name: name.to_string(),
42            components,
43        }
44    }
45
46    /// Return all built-in expression presets.
47    ///
48    /// Target stems correspond to actual MakeHuman expression unit targets
49    /// found under `expression/units/{ethnicity}/`.
50    pub fn all() -> Vec<ExpressionPreset> {
51        vec![
52            // neutral — baseline, no components
53            ExpressionPreset::new("neutral", vec![]),
54            // smile — corner puller + mouth elevation
55            ExpressionPreset::new(
56                "smile",
57                vec![
58                    ExpressionComponent::new("mouth-corner-puller", 0.8),
59                    ExpressionComponent::new("mouth-elevation", 0.5),
60                ],
61            ),
62            // frown — mouth depression + eyebrows down
63            ExpressionPreset::new(
64                "frown",
65                vec![
66                    ExpressionComponent::new("mouth-depression", 0.7),
67                    ExpressionComponent::new("eyebrows-left-down", 0.5),
68                    ExpressionComponent::new("eyebrows-right-down", 0.5),
69                ],
70            ),
71            // surprised — mouth open + eyebrows up + eyes opened up
72            ExpressionPreset::new(
73                "surprised",
74                vec![
75                    ExpressionComponent::new("mouth-open", 0.7),
76                    ExpressionComponent::new("eyebrows-left-up", 0.8),
77                    ExpressionComponent::new("eyebrows-right-up", 0.8),
78                    ExpressionComponent::new("eye-left-opened-up", 0.6),
79                    ExpressionComponent::new("eye-right-opened-up", 0.6),
80                ],
81            ),
82            // angry — eyebrows inner down + mouth compression
83            ExpressionPreset::new(
84                "angry",
85                vec![
86                    ExpressionComponent::new("eyebrows-left-inner-up", 0.0),
87                    ExpressionComponent::new("eyebrows-right-inner-up", 0.0),
88                    ExpressionComponent::new("eyebrows-left-down", 0.8),
89                    ExpressionComponent::new("eyebrows-right-down", 0.8),
90                    ExpressionComponent::new("mouth-compression", 0.6),
91                    ExpressionComponent::new("mouth-retraction", 0.3),
92                ],
93            ),
94            // sad — mouth depression + eyebrows inner up (classic sad brow) + eye slit
95            ExpressionPreset::new(
96                "sad",
97                vec![
98                    ExpressionComponent::new("mouth-depression", 0.6),
99                    ExpressionComponent::new("eyebrows-left-inner-up", 0.7),
100                    ExpressionComponent::new("eyebrows-right-inner-up", 0.7),
101                    ExpressionComponent::new("eye-left-slit", 0.3),
102                    ExpressionComponent::new("eye-right-slit", 0.3),
103                ],
104            ),
105        ]
106    }
107
108    /// Find a preset by name (case-insensitive).
109    pub fn from_name(name: &str) -> Option<ExpressionPreset> {
110        let lower = name.to_lowercase();
111        ExpressionPreset::all()
112            .into_iter()
113            .find(|p| p.name == lower)
114    }
115
116    /// Return all preset names.
117    pub fn all_names() -> Vec<&'static str> {
118        vec!["neutral", "smile", "frown", "surprised", "angry", "sad"]
119    }
120}
121
122/// Load expression targets from a directory and apply a preset to a HumanEngine.
123///
124/// Targets are looked up as `{expression_dir}/{component.target_name}.target`.
125/// Each matched target is loaded with a constant weight function returning the component weight.
126/// Returns the count of successfully applied targets.
127///
128/// Missing target files are silently skipped (graceful degradation).
129pub fn apply_expression_to_engine(
130    engine: &mut HumanEngine,
131    preset: &ExpressionPreset,
132    expression_dir: &std::path::Path,
133) -> usize {
134    use oxihuman_core::parser::target::parse_target;
135
136    if !expression_dir.exists() {
137        return 0;
138    }
139
140    let mut count = 0usize;
141    for component in &preset.components {
142        let target_path = expression_dir.join(format!("{}.target", component.target_name));
143        if !target_path.exists() {
144            continue;
145        }
146        let src = match std::fs::read_to_string(&target_path) {
147            Ok(s) => s,
148            Err(_) => continue,
149        };
150        let target = match parse_target(&component.target_name, &src) {
151            Ok(t) => t,
152            Err(_) => continue,
153        };
154        let w = component.weight;
155        engine.load_target(target, Box::new(move |_p: &ParamState| w));
156        count += 1;
157    }
158    count
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn all_presets_have_names() {
167        assert!(
168            ExpressionPreset::all_names().len() >= 5,
169            "expected at least 5 expression presets"
170        );
171    }
172
173    #[test]
174    fn neutral_preset_has_no_components() {
175        let preset = ExpressionPreset::from_name("neutral").expect("neutral must exist");
176        assert!(
177            preset.components.is_empty(),
178            "neutral should have no components"
179        );
180    }
181
182    #[test]
183    fn from_name_case_insensitive() {
184        let lower = ExpressionPreset::from_name("smile").expect("smile must exist");
185        let upper = ExpressionPreset::from_name("SMILE").expect("SMILE must exist");
186        assert_eq!(lower.name, upper.name);
187        assert_eq!(lower.components.len(), upper.components.len());
188    }
189
190    #[test]
191    fn from_name_unknown_returns_none() {
192        assert!(ExpressionPreset::from_name("xyzzy").is_none());
193    }
194
195    #[test]
196    fn preset_components_have_valid_weights() {
197        for preset in ExpressionPreset::all() {
198            for comp in &preset.components {
199                assert!(
200                    (0.0..=1.0).contains(&comp.weight),
201                    "preset '{}' component '{}' has weight {} out of [0,1]",
202                    preset.name,
203                    comp.target_name,
204                    comp.weight
205                );
206            }
207        }
208    }
209
210    #[test]
211    fn apply_expression_skips_missing_targets() {
212        use oxihuman_core::parser::obj::ObjMesh;
213        use oxihuman_core::policy::{Policy, PolicyProfile};
214        // Minimal valid base mesh (one triangle)
215        let base = ObjMesh {
216            positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
217            normals: vec![[0.0, 0.0, 1.0]; 3],
218            uvs: vec![[0.0, 0.0]; 3],
219            indices: vec![0, 1, 2],
220        };
221        let policy = Policy::new(PolicyProfile::Standard);
222        let mut engine = HumanEngine::new(base, policy);
223        let preset = ExpressionPreset::from_name("smile").expect("smile must exist");
224        // Non-existent directory — must return 0 without panicking
225        let count = apply_expression_to_engine(
226            &mut engine,
227            &preset,
228            std::path::Path::new("/tmp/nonexistent_expression_dir_oxihuman"),
229        );
230        assert_eq!(
231            count, 0,
232            "should return 0 when expression dir does not exist"
233        );
234    }
235
236    #[test]
237    fn all_preset_names_resolve() {
238        for name in ExpressionPreset::all_names() {
239            assert!(
240                ExpressionPreset::from_name(name).is_some(),
241                "preset name '{}' must resolve via from_name",
242                name
243            );
244        }
245    }
246}