Skip to main content

oxihuman_morph/
beard_density_control.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! Beard and facial hair density morph parameters.
6
7/// Facial hair zone.
8#[allow(dead_code)]
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub enum BeardZone {
11    Mustache,
12    ChinBeard,
13    Cheeks,
14    Sideburns,
15    Neck,
16}
17
18impl BeardZone {
19    #[allow(dead_code)]
20    pub fn name(self) -> &'static str {
21        match self {
22            BeardZone::Mustache => "mustache",
23            BeardZone::ChinBeard => "chin_beard",
24            BeardZone::Cheeks => "cheeks",
25            BeardZone::Sideburns => "sideburns",
26            BeardZone::Neck => "neck",
27        }
28    }
29}
30
31/// Per-zone beard density entry.
32#[allow(dead_code)]
33#[derive(Debug, Clone)]
34pub struct BeardZoneEntry {
35    pub zone: BeardZone,
36    pub density: f32,
37    pub length: f32,
38    pub thickness: f32,
39}
40
41impl BeardZoneEntry {
42    #[allow(dead_code)]
43    pub fn new(zone: BeardZone) -> Self {
44        BeardZoneEntry {
45            zone,
46            density: 0.0,
47            length: 0.0,
48            thickness: 0.0,
49        }
50    }
51}
52
53/// Beard density state across all zones.
54#[allow(dead_code)]
55#[derive(Debug, Clone)]
56pub struct BeardDensityState {
57    pub zones: Vec<BeardZoneEntry>,
58    pub global_density: f32,
59}
60
61#[allow(dead_code)]
62pub fn default_beard_density_state() -> BeardDensityState {
63    let zones = vec![
64        BeardZoneEntry::new(BeardZone::Mustache),
65        BeardZoneEntry::new(BeardZone::ChinBeard),
66        BeardZoneEntry::new(BeardZone::Cheeks),
67        BeardZoneEntry::new(BeardZone::Sideburns),
68        BeardZoneEntry::new(BeardZone::Neck),
69    ];
70    BeardDensityState {
71        zones,
72        global_density: 0.0,
73    }
74}
75
76#[allow(dead_code)]
77pub fn bd_set_global(state: &mut BeardDensityState, v: f32) {
78    state.global_density = v.clamp(0.0, 1.0);
79    for z in &mut state.zones {
80        z.density = v.clamp(0.0, 1.0);
81    }
82}
83
84#[allow(dead_code)]
85pub fn bd_set_zone_density(state: &mut BeardDensityState, zone: BeardZone, v: f32) {
86    for z in &mut state.zones {
87        if z.zone == zone {
88            z.density = v.clamp(0.0, 1.0);
89            return;
90        }
91    }
92}
93
94#[allow(dead_code)]
95pub fn bd_set_zone_length(state: &mut BeardDensityState, zone: BeardZone, v: f32) {
96    for z in &mut state.zones {
97        if z.zone == zone {
98            z.length = v.clamp(0.0, 1.0);
99            return;
100        }
101    }
102}
103
104#[allow(dead_code)]
105pub fn bd_reset(state: &mut BeardDensityState) {
106    for z in &mut state.zones {
107        z.density = 0.0;
108        z.length = 0.0;
109        z.thickness = 0.0;
110    }
111    state.global_density = 0.0;
112}
113
114#[allow(dead_code)]
115pub fn bd_is_clean_shaven(state: &BeardDensityState) -> bool {
116    state.zones.iter().all(|z| z.density < 1e-6)
117}
118
119#[allow(dead_code)]
120pub fn bd_average_density(state: &BeardDensityState) -> f32 {
121    if state.zones.is_empty() {
122        return 0.0;
123    }
124    let sum: f32 = state.zones.iter().map(|z| z.density).sum();
125    sum / state.zones.len() as f32
126}
127
128#[allow(dead_code)]
129pub fn bd_to_json(state: &BeardDensityState) -> String {
130    format!(
131        r#"{{"global_density":{:.4},"zone_count":{}}}"#,
132        state.global_density,
133        state.zones.len()
134    )
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn default_is_clean_shaven() {
143        assert!(bd_is_clean_shaven(&default_beard_density_state()));
144    }
145
146    #[test]
147    fn set_global_density() {
148        let mut s = default_beard_density_state();
149        bd_set_global(&mut s, 0.8);
150        assert!((s.global_density - 0.8).abs() < 1e-5);
151    }
152
153    #[test]
154    fn set_global_sets_all_zones() {
155        let mut s = default_beard_density_state();
156        bd_set_global(&mut s, 0.5);
157        assert!(!bd_is_clean_shaven(&s));
158    }
159
160    #[test]
161    fn set_zone_density_clamps() {
162        let mut s = default_beard_density_state();
163        bd_set_zone_density(&mut s, BeardZone::Mustache, 2.0);
164        let z = s
165            .zones
166            .iter()
167            .find(|z| z.zone == BeardZone::Mustache)
168            .expect("should succeed");
169        assert!((z.density - 1.0).abs() < 1e-6);
170    }
171
172    #[test]
173    fn reset_clears_all() {
174        let mut s = default_beard_density_state();
175        bd_set_global(&mut s, 1.0);
176        bd_reset(&mut s);
177        assert!(bd_is_clean_shaven(&s));
178    }
179
180    #[test]
181    fn average_density_zero_default() {
182        let s = default_beard_density_state();
183        assert!(bd_average_density(&s).abs() < 1e-6);
184    }
185
186    #[test]
187    fn average_density_after_global_set() {
188        let mut s = default_beard_density_state();
189        bd_set_global(&mut s, 0.6);
190        assert!((bd_average_density(&s) - 0.6).abs() < 1e-5);
191    }
192
193    #[test]
194    fn zone_length_set() {
195        let mut s = default_beard_density_state();
196        bd_set_zone_length(&mut s, BeardZone::ChinBeard, 0.7);
197        let z = s
198            .zones
199            .iter()
200            .find(|z| z.zone == BeardZone::ChinBeard)
201            .expect("should succeed");
202        assert!((z.length - 0.7).abs() < 1e-5);
203    }
204
205    #[test]
206    fn to_json_has_global_density() {
207        assert!(bd_to_json(&default_beard_density_state()).contains("global_density"));
208    }
209}