Skip to main content

oxihuman_morph/
expression_blend.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Facial expression blending system.
5//!
6//! Provides [`ExpressionDef`], a descriptor for a named compound expression that
7//! is composed of weighted morph-target contributions, and [`ExpressionBlender`],
8//! a runtime library that resolves expression names to concrete morph-target
9//! weight maps, supports additive multi-expression blending, smooth lerp
10//! between two expressions, and FACS Action Unit → weight mapping.
11//!
12//! # Quick start
13//!
14//! ```rust
15//! use oxihuman_morph::expression_blend::ExpressionBlender;
16//!
17//! let blender = ExpressionBlender::with_defaults();
18//! let weights = blender.blend_to_weights("Happy", 0.8).unwrap_or_default();
19//! assert!(weights.contains_key("mouth-corner-puller"));
20//! ```
21
22#![allow(dead_code)]
23
24use std::collections::HashMap;
25
26// ---------------------------------------------------------------------------
27// ExpressionDef
28// ---------------------------------------------------------------------------
29
30/// Definition of a named compound expression made up of weighted morph-target
31/// contributions.
32#[derive(Debug, Clone)]
33pub struct ExpressionDef {
34    /// Unique name, e.g. `"Happy"`.
35    pub name: String,
36    /// Ordered list of `(morph_target_name, weight)` pairs that together
37    /// compose this expression.  Weights are in `[0.0, 1.0]`.
38    pub targets: Vec<(String, f64)>,
39    /// Semantic tags (e.g. `["positive", "mouth", "eyes"]`).
40    pub tags: Vec<String>,
41    /// Optional name of the bilateral-symmetric counterpart expression,
42    /// e.g. `"Contempt"` for `"Contempt_R"`.
43    pub symmetry_pair: Option<String>,
44}
45
46impl ExpressionDef {
47    /// Construct a new expression definition.
48    pub fn new(name: impl Into<String>, targets: Vec<(impl Into<String>, f64)>) -> Self {
49        Self {
50            name: name.into(),
51            targets: targets.into_iter().map(|(t, w)| (t.into(), w)).collect(),
52            tags: Vec::new(),
53            symmetry_pair: None,
54        }
55    }
56
57    /// Builder: set tags.
58    pub fn with_tags(mut self, tags: impl IntoIterator<Item = impl Into<String>>) -> Self {
59        self.tags = tags.into_iter().map(|t| t.into()).collect();
60        self
61    }
62
63    /// Builder: set the symmetry pair.
64    pub fn with_symmetry_pair(mut self, pair: impl Into<String>) -> Self {
65        self.symmetry_pair = Some(pair.into());
66        self
67    }
68}
69
70// ---------------------------------------------------------------------------
71// Pre-defined expression library helpers
72// ---------------------------------------------------------------------------
73
74fn make_def(
75    name: &str,
76    targets: &[(&str, f64)],
77    tags: &[&str],
78    sym: Option<&str>,
79) -> ExpressionDef {
80    let mut def = ExpressionDef::new(
81        name,
82        targets
83            .iter()
84            .map(|(t, w)| (t.to_string(), *w))
85            .collect::<Vec<_>>(),
86    )
87    .with_tags(tags.iter().copied());
88    if let Some(s) = sym {
89        def = def.with_symmetry_pair(s);
90    }
91    def
92}
93
94/// Return the eight basic Ekman emotions plus several derived expressions as
95/// pre-built [`ExpressionDef`]s, ready to be inserted into an
96/// [`ExpressionBlender`].
97pub fn default_expression_defs() -> Vec<ExpressionDef> {
98    vec![
99        // ----------------------------------------------------------------
100        // 1. Neutral — baseline
101        // ----------------------------------------------------------------
102        make_def("Neutral", &[], &["neutral", "baseline"], None),
103        // ----------------------------------------------------------------
104        // 2. Happy — lip corners up, cheek raise, inner brow oblique
105        // ----------------------------------------------------------------
106        make_def(
107            "Happy",
108            &[
109                ("mouth-corner-puller", 0.85),
110                ("mouth-elevation", 0.55),
111                ("cheek-raise-left", 0.60),
112                ("cheek-raise-right", 0.60),
113                ("eye-left-slight-close", 0.25),
114                ("eye-right-slight-close", 0.25),
115            ],
116            &["positive", "smile", "basic"],
117            None,
118        ),
119        // ----------------------------------------------------------------
120        // 3. Sad — inner brow raise, mouth depression, eyes heavy
121        // ----------------------------------------------------------------
122        make_def(
123            "Sad",
124            &[
125                ("eyebrows-left-inner-up", 0.75),
126                ("eyebrows-right-inner-up", 0.75),
127                ("mouth-depression", 0.65),
128                ("eye-left-opened-down", 0.35),
129                ("eye-right-opened-down", 0.35),
130                ("lower-lip-depression", 0.40),
131            ],
132            &["negative", "basic", "brow"],
133            Some("Happy"),
134        ),
135        // ----------------------------------------------------------------
136        // 4. Angry — brow down-and-in, lip compression, nose wrinkle
137        // ----------------------------------------------------------------
138        make_def(
139            "Angry",
140            &[
141                ("eyebrows-left-down", 0.80),
142                ("eyebrows-right-down", 0.80),
143                ("eyebrows-left-inner-down", 0.70),
144                ("eyebrows-right-inner-down", 0.70),
145                ("mouth-compression", 0.60),
146                ("mouth-retraction", 0.30),
147                ("nose-wrinkle", 0.40),
148            ],
149            &["negative", "basic", "brow", "mouth"],
150            None,
151        ),
152        // ----------------------------------------------------------------
153        // 5. Surprised — mouth open, brows up, eyes wide
154        // ----------------------------------------------------------------
155        make_def(
156            "Surprised",
157            &[
158                ("mouth-open", 0.75),
159                ("eyebrows-left-up", 0.85),
160                ("eyebrows-right-up", 0.85),
161                ("eye-left-opened-up", 0.65),
162                ("eye-right-opened-up", 0.65),
163                ("jaw-drop", 0.50),
164            ],
165            &["surprise", "basic", "eyes", "mouth"],
166            None,
167        ),
168        // ----------------------------------------------------------------
169        // 6. Disgusted — nose wrinkle, upper lip raise, brow down
170        // ----------------------------------------------------------------
171        make_def(
172            "Disgusted",
173            &[
174                ("nose-wrinkle", 0.75),
175                ("upper-lip-raise", 0.65),
176                ("eyebrows-left-down", 0.45),
177                ("eyebrows-right-down", 0.45),
178                ("mouth-left-corner-depression", 0.30),
179                ("mouth-right-corner-depression", 0.30),
180            ],
181            &["negative", "basic", "nose", "mouth"],
182            None,
183        ),
184        // ----------------------------------------------------------------
185        // 7. Fearful — brows up-and-together, eyes wide, mouth open
186        // ----------------------------------------------------------------
187        make_def(
188            "Fearful",
189            &[
190                ("eyebrows-left-up", 0.70),
191                ("eyebrows-right-up", 0.70),
192                ("eyebrows-left-inner-up", 0.65),
193                ("eyebrows-right-inner-up", 0.65),
194                ("eye-left-opened-up", 0.80),
195                ("eye-right-opened-up", 0.80),
196                ("mouth-open", 0.45),
197                ("mouth-stretch", 0.35),
198            ],
199            &["negative", "basic", "eyes", "brow"],
200            None,
201        ),
202        // ----------------------------------------------------------------
203        // 8. Contempt — unilateral lip corner raise (right side default)
204        // ----------------------------------------------------------------
205        make_def(
206            "Contempt",
207            &[
208                ("mouth-right-corner-puller", 0.70),
209                ("eyebrows-right-up", 0.30),
210                ("nose-wrinkle", 0.20),
211            ],
212            &["negative", "basic", "asymmetric"],
213            Some("Contempt_L"),
214        ),
215        // ----------------------------------------------------------------
216        // Derived / compound expressions
217        // ----------------------------------------------------------------
218
219        // Smirk (contempt-adjacent, asymmetric smile)
220        make_def(
221            "Smirk",
222            &[
223                ("mouth-right-corner-puller", 0.60),
224                ("eyebrows-right-up", 0.20),
225            ],
226            &["positive", "asymmetric"],
227            None,
228        ),
229        // Wink (surprise + blink blend)
230        make_def(
231            "Wink",
232            &[("eye-right-blink", 0.90), ("mouth-corner-puller", 0.30)],
233            &["playful", "asymmetric"],
234            None,
235        ),
236        // Pain
237        make_def(
238            "Pain",
239            &[
240                ("eyebrows-left-inner-up", 0.80),
241                ("eyebrows-right-inner-up", 0.80),
242                ("eyebrows-left-down", 0.50),
243                ("eyebrows-right-down", 0.50),
244                ("nose-wrinkle", 0.55),
245                ("mouth-compression", 0.50),
246                ("eye-left-slight-close", 0.70),
247                ("eye-right-slight-close", 0.70),
248            ],
249            &["negative", "brow", "eyes"],
250            None,
251        ),
252        // Boredom
253        make_def(
254            "Boredom",
255            &[
256                ("eye-left-slight-close", 0.50),
257                ("eye-right-slight-close", 0.50),
258                ("mouth-depression", 0.20),
259                ("eyebrows-left-down", 0.20),
260                ("eyebrows-right-down", 0.20),
261            ],
262            &["neutral", "eyes"],
263            None,
264        ),
265    ]
266}
267
268// ---------------------------------------------------------------------------
269// ExpressionBlender
270// ---------------------------------------------------------------------------
271
272/// Manages a library of [`ExpressionDef`]s and resolves them to morph-target
273/// weight maps at runtime.
274#[derive(Debug, Clone)]
275pub struct ExpressionBlender {
276    /// Map from expression name (lower-case-canonical) → definition.
277    library: HashMap<String, ExpressionDef>,
278}
279
280impl ExpressionBlender {
281    /// Create an empty blender with no pre-loaded expressions.
282    pub fn new() -> Self {
283        Self {
284            library: HashMap::new(),
285        }
286    }
287
288    /// Create a blender pre-loaded with the default expression library
289    /// ([`default_expression_defs`]).
290    pub fn with_defaults() -> Self {
291        let mut blender = Self::new();
292        for def in default_expression_defs() {
293            blender.add(def);
294        }
295        blender
296    }
297
298    /// Add an expression definition to the library.
299    /// If an expression with the same name already exists it is replaced.
300    pub fn add(&mut self, def: ExpressionDef) {
301        let key = Self::canonical(&def.name);
302        self.library.insert(key, def);
303    }
304
305    /// Look up an expression definition by name (case-insensitive).
306    pub fn get(&self, name: &str) -> Option<&ExpressionDef> {
307        self.library.get(&Self::canonical(name))
308    }
309
310    /// Return a sorted list of all expression names (original casing).
311    pub fn list_names(&self) -> Vec<&str> {
312        let mut names: Vec<&str> = self.library.values().map(|d| d.name.as_str()).collect();
313        names.sort_unstable();
314        names
315    }
316
317    /// Number of expressions in the library.
318    pub fn len(&self) -> usize {
319        self.library.len()
320    }
321
322    /// Return `true` if the library is empty.
323    pub fn is_empty(&self) -> bool {
324        self.library.is_empty()
325    }
326
327    /// Map a single expression at `intensity` to a `HashMap<target, weight>`.
328    ///
329    /// Each per-target base weight is multiplied by `intensity`, then clamped
330    /// to `[0.0, 1.0]`.  Returns `None` if the expression name is not found.
331    pub fn blend_to_weights(&self, expr: &str, intensity: f64) -> Option<HashMap<String, f64>> {
332        let def = self.get(expr)?;
333        let scale = intensity.clamp(0.0, 1.0);
334        let mut out = HashMap::with_capacity(def.targets.len());
335        for (target, base_w) in &def.targets {
336            let w = (base_w * scale).clamp(0.0, 1.0);
337            out.insert(target.clone(), w);
338        }
339        Some(out)
340    }
341
342    /// Additively blend multiple expressions.
343    ///
344    /// `exprs` is a slice of `(expression_name, intensity)` pairs.
345    /// For each pair the expression is resolved at the given intensity and
346    /// its weights are **added** into the accumulator.  The final map is
347    /// clamped per-target to `[0.0, 1.0]`.  Unknown expression names are
348    /// silently skipped.
349    pub fn blend_multi(&self, exprs: &[(String, f64)]) -> HashMap<String, f64> {
350        let mut acc: HashMap<String, f64> = HashMap::new();
351        for (name, intensity) in exprs {
352            if let Some(weights) = self.blend_to_weights(name, *intensity) {
353                for (target, w) in weights {
354                    let entry = acc.entry(target).or_insert(0.0);
355                    *entry = (*entry + w).clamp(0.0, 1.0);
356                }
357            }
358        }
359        acc
360    }
361
362    /// Linearly interpolate between two expressions at parameter `t ∈ [0, 1]`.
363    ///
364    /// At `t = 0.0` the result equals `blend_to_weights(from, 1.0)`.
365    /// At `t = 1.0` the result equals `blend_to_weights(to, 1.0)`.
366    /// Intermediate values are computed per-target as `w_from * (1-t) + w_to * t`.
367    ///
368    /// Targets that appear in only one expression are treated as having weight
369    /// 0.0 in the other.  Returns an empty map if both expressions are unknown.
370    pub fn lerp_expression(&self, from: &str, to: &str, t: f64) -> HashMap<String, f64> {
371        let t = t.clamp(0.0, 1.0);
372        let from_map = self.blend_to_weights(from, 1.0).unwrap_or_default();
373        let to_map = self.blend_to_weights(to, 1.0).unwrap_or_default();
374
375        // Collect union of all target keys
376        let mut keys: Vec<String> = from_map.keys().cloned().collect();
377        for k in to_map.keys() {
378            if !from_map.contains_key(k) {
379                keys.push(k.clone());
380            }
381        }
382
383        let mut out = HashMap::with_capacity(keys.len());
384        for key in keys {
385            let a = from_map.get(&key).copied().unwrap_or(0.0);
386            let b = to_map.get(&key).copied().unwrap_or(0.0);
387            let v = (a * (1.0 - t) + b * t).clamp(0.0, 1.0);
388            out.insert(key, v);
389        }
390        out
391    }
392
393    /// Map a FACS Action Unit code + intensity to morph-target weights.
394    ///
395    /// This implements an approximate mapping from the standard FACS AU numbers
396    /// (as used by Paul Ekman et al.) to OxiHuman morph-target names.
397    ///
398    /// The mapping is intentionally broad: each AU drives one or more targets.
399    /// `intensity` is in `[0.0, 1.0]`; the return value is a weight map with
400    /// all values clamped to `[0.0, 1.0]`.
401    ///
402    /// Unknown AU codes return an empty map rather than erroring.
403    pub fn au_to_expression(au_code: u32, intensity: f64) -> HashMap<String, f64> {
404        let scale = intensity.clamp(0.0, 1.0);
405        let targets: &[(&str, f64)] = match au_code {
406            // AU1  — Inner Brow Raise
407            1 => &[
408                ("eyebrows-left-inner-up", 1.0),
409                ("eyebrows-right-inner-up", 1.0),
410            ],
411            // AU2  — Outer Brow Raise
412            2 => &[("eyebrows-left-up", 1.0), ("eyebrows-right-up", 1.0)],
413            // AU4  — Brow Lowerer
414            4 => &[
415                ("eyebrows-left-down", 1.0),
416                ("eyebrows-right-down", 1.0),
417                ("eyebrows-left-inner-down", 0.70),
418                ("eyebrows-right-inner-down", 0.70),
419            ],
420            // AU5  — Upper Lid Raiser
421            5 => &[("eye-left-opened-up", 1.0), ("eye-right-opened-up", 1.0)],
422            // AU6  — Cheek Raiser
423            6 => &[("cheek-raise-left", 1.0), ("cheek-raise-right", 1.0)],
424            // AU7  — Lid Tightener
425            7 => &[
426                ("eye-left-slight-close", 0.70),
427                ("eye-right-slight-close", 0.70),
428            ],
429            // AU9  — Nose Wrinkler
430            9 => &[("nose-wrinkle", 1.0)],
431            // AU10 — Upper Lip Raiser
432            10 => &[("upper-lip-raise", 1.0)],
433            // AU11 — Nasolabial Deepener
434            11 => &[
435                ("nasolabial-deepener-left", 0.80),
436                ("nasolabial-deepener-right", 0.80),
437            ],
438            // AU12 — Lip Corner Puller (Smile muscle)
439            12 => &[("mouth-corner-puller", 1.0), ("mouth-elevation", 0.40)],
440            // AU13 — Cheek Puffer
441            13 => &[("cheek-puff-left", 0.80), ("cheek-puff-right", 0.80)],
442            // AU14 — Dimpler
443            14 => &[("cheek-dimple-left", 0.80), ("cheek-dimple-right", 0.80)],
444            // AU15 — Lip Corner Depressor
445            15 => &[
446                ("mouth-left-corner-depression", 0.90),
447                ("mouth-right-corner-depression", 0.90),
448                ("mouth-depression", 0.50),
449            ],
450            // AU16 — Lower Lip Depressor
451            16 => &[("lower-lip-depression", 1.0)],
452            // AU17 — Chin Raiser
453            17 => &[("chin-raise", 1.0)],
454            // AU18 — Lip Puckerer
455            18 => &[("lip-puckerer", 1.0)],
456            // AU20 — Lip Stretcher
457            20 => &[("mouth-stretch", 1.0)],
458            // AU22 — Lip Funneler
459            22 => &[("lip-funnel", 1.0)],
460            // AU23 — Lip Tightener
461            23 => &[("mouth-compression", 0.80)],
462            // AU24 — Lip Pressor
463            24 => &[("mouth-compression", 0.60), ("lip-press", 0.80)],
464            // AU25 — Lips Part
465            25 => &[("mouth-open", 0.50)],
466            // AU26 — Jaw Drop
467            26 => &[("jaw-drop", 1.0), ("mouth-open", 0.70)],
468            // AU27 — Mouth Stretch
469            27 => &[
470                ("mouth-open", 1.0),
471                ("jaw-drop", 0.80),
472                ("mouth-stretch", 0.60),
473            ],
474            // AU28 — Lip Suck
475            28 => &[("lip-suck", 1.0)],
476            // AU41 — Lid Droop
477            41 => &[
478                ("eye-left-opened-down", 0.80),
479                ("eye-right-opened-down", 0.80),
480            ],
481            // AU42 — Slit
482            42 => &[
483                ("eye-left-slight-close", 0.60),
484                ("eye-right-slight-close", 0.60),
485            ],
486            // AU43 — Eyes Closed
487            43 => &[("eye-left-blink", 1.0), ("eye-right-blink", 1.0)],
488            // AU44 — Squint
489            44 => &[
490                ("eye-left-slight-close", 0.90),
491                ("eye-right-slight-close", 0.90),
492                ("cheek-raise-left", 0.50),
493                ("cheek-raise-right", 0.50),
494            ],
495            // AU45 — Blink
496            45 => &[("eye-left-blink", 1.0), ("eye-right-blink", 1.0)],
497            // AU46 — Wink (right eye by convention)
498            46 => &[("eye-right-blink", 1.0)],
499            // Unknown AU
500            _ => &[],
501        };
502
503        let mut out = HashMap::with_capacity(targets.len());
504        for (t, base_w) in targets {
505            let w = (base_w * scale).clamp(0.0, 1.0);
506            out.insert(t.to_string(), w);
507        }
508        out
509    }
510
511    // -----------------------------------------------------------------------
512    // Private helpers
513    // -----------------------------------------------------------------------
514
515    fn canonical(name: &str) -> String {
516        name.to_lowercase()
517    }
518}
519
520impl Default for ExpressionBlender {
521    fn default() -> Self {
522        Self::with_defaults()
523    }
524}
525
526// ---------------------------------------------------------------------------
527// Tests
528// ---------------------------------------------------------------------------
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    fn blender() -> ExpressionBlender {
535        ExpressionBlender::with_defaults()
536    }
537
538    // ── ExpressionDef construction ──────────────────────────────────────────
539
540    #[test]
541    fn expression_def_basic_fields() {
542        let def = ExpressionDef::new("TestExpr", vec![("target-a", 0.5), ("target-b", 1.0)]);
543        assert_eq!(def.name, "TestExpr");
544        assert_eq!(def.targets.len(), 2);
545        assert!(def.tags.is_empty());
546        assert!(def.symmetry_pair.is_none());
547    }
548
549    #[test]
550    fn expression_def_with_tags_and_pair() {
551        let def = ExpressionDef::new("Expr", vec![("t", 1.0)])
552            .with_tags(["emotion", "face"])
553            .with_symmetry_pair("Expr_Mirror");
554        assert_eq!(def.tags, vec!["emotion", "face"]);
555        assert_eq!(def.symmetry_pair.as_deref(), Some("Expr_Mirror"));
556    }
557
558    // ── Default library presence ────────────────────────────────────────────
559
560    #[test]
561    fn default_library_has_eight_basic_expressions() {
562        let b = blender();
563        for name in [
564            "Neutral",
565            "Happy",
566            "Sad",
567            "Angry",
568            "Surprised",
569            "Disgusted",
570            "Fearful",
571            "Contempt",
572        ] {
573            assert!(b.get(name).is_some(), "missing basic expression: {name}");
574        }
575    }
576
577    #[test]
578    fn default_library_len_at_least_eight() {
579        let b = blender();
580        assert!(b.len() >= 8, "expected >= 8 expressions, got {}", b.len());
581    }
582
583    #[test]
584    fn list_names_is_sorted() {
585        let b = blender();
586        let names = b.list_names();
587        let mut sorted = names.clone();
588        sorted.sort_unstable();
589        assert_eq!(names, sorted);
590    }
591
592    // ── blend_to_weights ───────────────────────────────────────────────────
593
594    #[test]
595    fn blend_to_weights_happy_full() {
596        let b = blender();
597        let w = b.blend_to_weights("Happy", 1.0).expect("Happy not found");
598        assert!(w.contains_key("mouth-corner-puller"));
599        for v in w.values() {
600            assert!(*v >= 0.0 && *v <= 1.0, "weight out of range: {v}");
601        }
602    }
603
604    #[test]
605    fn blend_to_weights_scales_with_intensity() {
606        let b = blender();
607        let w_full = b.blend_to_weights("Happy", 1.0).expect("should succeed");
608        let w_half = b.blend_to_weights("Happy", 0.5).expect("should succeed");
609        for (k, v_full) in &w_full {
610            let v_half = w_half[k];
611            assert!(
612                (v_half - v_full * 0.5).abs() < 1e-10,
613                "scale mismatch for {k}: full={v_full} half={v_half}"
614            );
615        }
616    }
617
618    #[test]
619    fn blend_to_weights_zero_intensity_returns_all_zeros() {
620        let b = blender();
621        let w = b.blend_to_weights("Happy", 0.0).expect("should succeed");
622        for v in w.values() {
623            assert_eq!(*v, 0.0);
624        }
625    }
626
627    #[test]
628    fn blend_to_weights_neutral_returns_empty_map() {
629        let b = blender();
630        let w = b.blend_to_weights("Neutral", 1.0).expect("should succeed");
631        assert!(w.is_empty(), "Neutral should have no targets");
632    }
633
634    #[test]
635    fn blend_to_weights_unknown_expression_returns_none() {
636        let b = blender();
637        assert!(b.blend_to_weights("NonExistentXYZ", 1.0).is_none());
638    }
639
640    #[test]
641    fn blend_to_weights_clamps_intensity_over_one() {
642        let b = blender();
643        let w_one = b.blend_to_weights("Happy", 1.0).expect("should succeed");
644        let w_over = b.blend_to_weights("Happy", 2.5).expect("should succeed");
645        assert_eq!(w_one, w_over, "intensity > 1.0 should be clamped to 1.0");
646    }
647
648    #[test]
649    fn blend_to_weights_clamps_intensity_negative() {
650        let b = blender();
651        let w = b.blend_to_weights("Happy", -0.5).expect("should succeed");
652        for v in w.values() {
653            assert_eq!(*v, 0.0);
654        }
655    }
656
657    // ── blend_multi ────────────────────────────────────────────────────────
658
659    #[test]
660    fn blend_multi_two_expressions() {
661        let b = blender();
662        let exprs = vec![("Happy".to_string(), 0.6), ("Sad".to_string(), 0.4)];
663        let w = b.blend_multi(&exprs);
664        assert!(!w.is_empty());
665        for v in w.values() {
666            assert!(*v >= 0.0 && *v <= 1.0);
667        }
668    }
669
670    #[test]
671    fn blend_multi_skips_unknown_names() {
672        let b = blender();
673        let exprs = vec![("Happy".to_string(), 1.0), ("UnknownXYZ".to_string(), 1.0)];
674        let w = b.blend_multi(&exprs);
675        // Should match Happy alone
676        let w_happy = b.blend_to_weights("Happy", 1.0).expect("should succeed");
677        for k in w_happy.keys() {
678            assert!(w.contains_key(k));
679        }
680    }
681
682    #[test]
683    fn blend_multi_clamped_at_one() {
684        let b = blender();
685        // Blend Happy at full intensity twice — additive should not exceed 1.0
686        let exprs = vec![("Happy".to_string(), 1.0), ("Happy".to_string(), 1.0)];
687        let w = b.blend_multi(&exprs);
688        for v in w.values() {
689            assert!(*v <= 1.0, "clamp failed: {v}");
690        }
691    }
692
693    #[test]
694    fn blend_multi_empty_input() {
695        let b = blender();
696        let w = b.blend_multi(&[]);
697        assert!(w.is_empty());
698    }
699
700    // ── lerp_expression ────────────────────────────────────────────────────
701
702    #[test]
703    fn lerp_at_t0_equals_from() {
704        let b = blender();
705        let lerped = b.lerp_expression("Happy", "Sad", 0.0);
706        let from = b.blend_to_weights("Happy", 1.0).expect("should succeed");
707        for (k, v) in &from {
708            let lv = lerped.get(k).copied().unwrap_or(0.0);
709            assert!((lv - v).abs() < 1e-10, "t=0 mismatch for {k}: {lv} vs {v}");
710        }
711    }
712
713    #[test]
714    fn lerp_at_t1_equals_to() {
715        let b = blender();
716        let lerped = b.lerp_expression("Happy", "Sad", 1.0);
717        let to_map = b.blend_to_weights("Sad", 1.0).expect("should succeed");
718        for (k, v) in &to_map {
719            let lv = lerped.get(k).copied().unwrap_or(0.0);
720            assert!((lv - v).abs() < 1e-10, "t=1 mismatch for {k}: {lv} vs {v}");
721        }
722    }
723
724    #[test]
725    fn lerp_at_t_half_midpoint() {
726        let b = blender();
727        let lerped = b.lerp_expression("Happy", "Angry", 0.5);
728        let happy = b.blend_to_weights("Happy", 1.0).expect("should succeed");
729        let angry = b.blend_to_weights("Angry", 1.0).expect("should succeed");
730        for k in happy.keys().chain(angry.keys()) {
731            let a = happy.get(k).copied().unwrap_or(0.0);
732            let c = angry.get(k).copied().unwrap_or(0.0);
733            let expected = (a * 0.5 + c * 0.5).clamp(0.0, 1.0);
734            let got = lerped.get(k).copied().unwrap_or(0.0);
735            assert!(
736                (got - expected).abs() < 1e-10,
737                "midpoint mismatch for {k}: expected {expected} got {got}"
738            );
739        }
740    }
741
742    #[test]
743    fn lerp_unknown_from_returns_to_at_t1() {
744        let b = blender();
745        let lerped = b.lerp_expression("NonExistent", "Happy", 1.0);
746        let to_map = b.blend_to_weights("Happy", 1.0).expect("should succeed");
747        for (k, v) in &to_map {
748            let lv = lerped.get(k).copied().unwrap_or(0.0);
749            assert!((lv - v).abs() < 1e-10, "t=1 mismatch for {k}");
750        }
751    }
752
753    #[test]
754    fn lerp_values_always_in_unit_interval() {
755        let b = blender();
756        for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
757            let map = b.lerp_expression("Fearful", "Disgusted", t);
758            for v in map.values() {
759                assert!(*v >= 0.0 && *v <= 1.0, "out of range at t={t}: {v}");
760            }
761        }
762    }
763
764    // ── au_to_expression ───────────────────────────────────────────────────
765
766    #[test]
767    fn au_12_produces_lip_corner_puller() {
768        let w = ExpressionBlender::au_to_expression(12, 1.0);
769        assert!(
770            w.contains_key("mouth-corner-puller"),
771            "AU12 should drive mouth-corner-puller"
772        );
773    }
774
775    #[test]
776    fn au_4_produces_brow_lowerer() {
777        let w = ExpressionBlender::au_to_expression(4, 1.0);
778        assert!(
779            w.contains_key("eyebrows-left-down"),
780            "AU4 should lower brows"
781        );
782        assert!(w.contains_key("eyebrows-right-down"));
783    }
784
785    #[test]
786    fn au_intensity_scales_correctly() {
787        let w_full = ExpressionBlender::au_to_expression(12, 1.0);
788        let w_half = ExpressionBlender::au_to_expression(12, 0.5);
789        for (k, v_full) in &w_full {
790            let v_half = w_half[k];
791            assert!(
792                (v_half - v_full * 0.5).abs() < 1e-10,
793                "AU12 scale mismatch for {k}"
794            );
795        }
796    }
797
798    #[test]
799    fn au_unknown_code_returns_empty() {
800        let w = ExpressionBlender::au_to_expression(999, 1.0);
801        assert!(w.is_empty(), "unknown AU should return empty map");
802    }
803
804    #[test]
805    fn au_zero_intensity_returns_all_zeros() {
806        let w = ExpressionBlender::au_to_expression(6, 0.0);
807        for v in w.values() {
808            assert_eq!(*v, 0.0);
809        }
810    }
811
812    #[test]
813    fn au_values_always_clamped() {
814        for au in [1, 2, 4, 5, 6, 7, 9, 12, 15, 25, 26, 43, 45, 46] {
815            let w = ExpressionBlender::au_to_expression(au, 1.5);
816            for v in w.values() {
817                assert!(*v <= 1.0, "AU{au} weight > 1.0: {v}");
818            }
819        }
820    }
821
822    #[test]
823    fn au_45_blink_both_eyes() {
824        let w = ExpressionBlender::au_to_expression(45, 1.0);
825        assert!(w.contains_key("eye-left-blink"));
826        assert!(w.contains_key("eye-right-blink"));
827    }
828
829    #[test]
830    fn au_46_wink_right_eye_only() {
831        let w = ExpressionBlender::au_to_expression(46, 1.0);
832        assert!(w.contains_key("eye-right-blink"));
833        assert!(
834            !w.contains_key("eye-left-blink"),
835            "AU46 wink should be right eye only"
836        );
837    }
838
839    // ── case-insensitive lookup ─────────────────────────────────────────────
840
841    #[test]
842    fn get_is_case_insensitive() {
843        let b = blender();
844        assert!(b.get("happy").is_some());
845        assert!(b.get("HAPPY").is_some());
846        assert!(b.get("HaPpY").is_some());
847    }
848
849    // ── tags ───────────────────────────────────────────────────────────────
850
851    #[test]
852    fn happy_has_basic_tag() {
853        let b = blender();
854        let def = b.get("Happy").expect("should succeed");
855        assert!(
856            def.tags.iter().any(|t| t == "basic"),
857            "Happy should have 'basic' tag"
858        );
859    }
860
861    #[test]
862    fn contempt_has_symmetry_pair() {
863        let b = blender();
864        let def = b.get("Contempt").expect("should succeed");
865        assert!(
866            def.symmetry_pair.is_some(),
867            "Contempt should have a symmetry_pair"
868        );
869    }
870}