Skip to main content

oxihuman_morph/
body_hair.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Procedural body hair parameters and generation.
5
6/// Simple LCG pseudo-random number generator (no external crate).
7struct Lcg(u64);
8
9impl Lcg {
10    fn new(seed: u64) -> Self {
11        Self(seed.wrapping_add(1))
12    }
13
14    fn next_u64(&mut self) -> u64 {
15        self.0 = self
16            .0
17            .wrapping_mul(6364136223846793005)
18            .wrapping_add(1442695040888963407);
19        self.0
20    }
21
22    /// Returns a value in [0, 1).
23    fn next_f32(&mut self) -> f32 {
24        (self.next_u64() >> 11) as f32 / (1u64 << 53) as f32
25    }
26
27    /// Returns a value in [lo, hi).
28    fn range_f32(&mut self, lo: f32, hi: f32) -> f32 {
29        lo + self.next_f32() * (hi - lo)
30    }
31}
32
33#[allow(dead_code)]
34pub struct HairRegion {
35    pub name: String,
36    pub density: f32,
37    pub length: f32,
38    pub length_variance: f32,
39    pub curl: f32,
40    pub color: [f32; 3],
41    pub enabled: bool,
42}
43
44#[allow(dead_code)]
45pub struct HairProfile {
46    pub regions: Vec<HairRegion>,
47    pub global_density_scale: f32,
48    pub global_length_scale: f32,
49}
50
51#[allow(dead_code)]
52pub struct HairStrand {
53    pub root: [f32; 3],
54    pub tip: [f32; 3],
55    pub thickness: f32,
56    pub color: [f32; 3],
57}
58
59#[allow(dead_code)]
60pub struct HairGenerationParams {
61    pub seed: u64,
62    pub lod: u8,
63}
64
65#[allow(dead_code)]
66pub fn default_hair_profile() -> HairProfile {
67    HairProfile {
68        regions: vec![
69            HairRegion {
70                name: "scalp".to_string(),
71                density: 150.0,
72                length: 120.0,
73                length_variance: 30.0,
74                curl: 0.1,
75                color: [0.2, 0.15, 0.1],
76                enabled: true,
77            },
78            HairRegion {
79                name: "eyebrow_left".to_string(),
80                density: 80.0,
81                length: 10.0,
82                length_variance: 2.0,
83                curl: 0.05,
84                color: [0.18, 0.12, 0.08],
85                enabled: true,
86            },
87            HairRegion {
88                name: "eyebrow_right".to_string(),
89                density: 80.0,
90                length: 10.0,
91                length_variance: 2.0,
92                curl: 0.05,
93                color: [0.18, 0.12, 0.08],
94                enabled: true,
95            },
96            HairRegion {
97                name: "eyelash_upper".to_string(),
98                density: 100.0,
99                length: 12.0,
100                length_variance: 2.5,
101                curl: 0.3,
102                color: [0.1, 0.08, 0.05],
103                enabled: true,
104            },
105            HairRegion {
106                name: "eyelash_lower".to_string(),
107                density: 60.0,
108                length: 8.0,
109                length_variance: 1.5,
110                curl: 0.2,
111                color: [0.1, 0.08, 0.05],
112                enabled: true,
113            },
114            HairRegion {
115                name: "beard".to_string(),
116                density: 40.0,
117                length: 5.0,
118                length_variance: 2.0,
119                curl: 0.1,
120                color: [0.2, 0.15, 0.1],
121                enabled: false,
122            },
123            HairRegion {
124                name: "armpit".to_string(),
125                density: 20.0,
126                length: 25.0,
127                length_variance: 5.0,
128                curl: 0.2,
129                color: [0.22, 0.17, 0.12],
130                enabled: true,
131            },
132            HairRegion {
133                name: "pubic".to_string(),
134                density: 30.0,
135                length: 20.0,
136                length_variance: 5.0,
137                curl: 0.5,
138                color: [0.2, 0.15, 0.1],
139                enabled: true,
140            },
141        ],
142        global_density_scale: 1.0,
143        global_length_scale: 1.0,
144    }
145}
146
147#[allow(dead_code)]
148pub fn add_region(profile: &mut HairProfile, region: HairRegion) {
149    profile.regions.push(region);
150}
151
152#[allow(dead_code)]
153pub fn scale_density(profile: &mut HairProfile, factor: f32) {
154    profile.global_density_scale *= factor;
155}
156
157#[allow(dead_code)]
158pub fn hair_count_for_region(region: &HairRegion, area_cm2: f32) -> usize {
159    if !region.enabled {
160        return 0;
161    }
162    (region.density * area_cm2).round() as usize
163}
164
165#[allow(dead_code)]
166pub fn generate_strands(
167    region: &HairRegion,
168    positions: &[[f32; 3]],
169    normals: &[[f32; 3]],
170    params: &HairGenerationParams,
171) -> Vec<HairStrand> {
172    if !region.enabled || positions.is_empty() {
173        return Vec::new();
174    }
175    let lod_factor = lod_density_factor(params.lod);
176    let count = (positions.len() as f32 * lod_factor).round() as usize;
177    let count = count.min(positions.len());
178
179    let mut rng = Lcg::new(params.seed);
180    let mut strands = Vec::with_capacity(count);
181    for (i, root) in positions.iter().enumerate().take(count) {
182        let normal = if i < normals.len() {
183            normals[i]
184        } else {
185            [0.0, 1.0, 0.0]
186        };
187        let length_var = rng.range_f32(-region.length_variance, region.length_variance);
188        let length = (region.length + length_var).max(0.1) * 0.001; // mm to m
189        let tip = curl_tip(
190            *root,
191            normal,
192            length,
193            region.curl,
194            params.seed.wrapping_add(i as u64),
195        );
196        let thickness = rng.range_f32(0.04, 0.08);
197        strands.push(HairStrand {
198            root: *root,
199            tip,
200            thickness,
201            color: region.color,
202        });
203    }
204    strands
205}
206
207#[allow(dead_code)]
208pub fn curl_tip(root: [f32; 3], normal: [f32; 3], length: f32, curl: f32, seed: u64) -> [f32; 3] {
209    let s = seed
210        .wrapping_mul(6364136223846793005)
211        .wrapping_add(1442695040888963407);
212    let angle = (s as f32 / u64::MAX as f32) * std::f32::consts::TAU;
213    let curl_offset = curl * length * 0.5;
214    [
215        root[0] + normal[0] * length + angle.cos() * curl_offset,
216        root[1] + normal[1] * length + angle.sin() * curl_offset,
217        root[2] + normal[2] * length,
218    ]
219}
220
221#[allow(dead_code)]
222pub fn total_strand_count(profile: &HairProfile, area_cm2: f32) -> usize {
223    profile
224        .regions
225        .iter()
226        .filter(|r| r.enabled)
227        .map(|r| {
228            let effective_density = r.density * profile.global_density_scale;
229            (effective_density * area_cm2).round() as usize
230        })
231        .sum()
232}
233
234#[allow(dead_code)]
235pub fn region_by_name<'a>(profile: &'a HairProfile, name: &str) -> Option<&'a HairRegion> {
236    profile.regions.iter().find(|r| r.name == name)
237}
238
239#[allow(dead_code)]
240pub fn blend_hair_profiles(a: &HairProfile, b: &HairProfile, t: f32) -> HairProfile {
241    let t = t.clamp(0.0, 1.0);
242    let count = a.regions.len().min(b.regions.len());
243    let mut regions = Vec::with_capacity(count);
244    for i in 0..count {
245        let ra = &a.regions[i];
246        let rb = &b.regions[i];
247        regions.push(HairRegion {
248            name: ra.name.clone(),
249            density: ra.density * (1.0 - t) + rb.density * t,
250            length: ra.length * (1.0 - t) + rb.length * t,
251            length_variance: ra.length_variance * (1.0 - t) + rb.length_variance * t,
252            curl: ra.curl * (1.0 - t) + rb.curl * t,
253            color: [
254                ra.color[0] * (1.0 - t) + rb.color[0] * t,
255                ra.color[1] * (1.0 - t) + rb.color[1] * t,
256                ra.color[2] * (1.0 - t) + rb.color[2] * t,
257            ],
258            enabled: if t < 0.5 { ra.enabled } else { rb.enabled },
259        });
260    }
261    HairProfile {
262        regions,
263        global_density_scale: a.global_density_scale * (1.0 - t) + b.global_density_scale * t,
264        global_length_scale: a.global_length_scale * (1.0 - t) + b.global_length_scale * t,
265    }
266}
267
268#[allow(dead_code)]
269pub fn hair_profile_to_params(profile: &HairProfile) -> Vec<(String, f32)> {
270    let mut out = Vec::new();
271    out.push((
272        "global_density_scale".to_string(),
273        profile.global_density_scale,
274    ));
275    out.push((
276        "global_length_scale".to_string(),
277        profile.global_length_scale,
278    ));
279    for r in &profile.regions {
280        let prefix = r.name.clone();
281        out.push((format!("{prefix}.density"), r.density));
282        out.push((format!("{prefix}.length"), r.length));
283        out.push((format!("{prefix}.length_variance"), r.length_variance));
284        out.push((format!("{prefix}.curl"), r.curl));
285        out.push((format!("{prefix}.color_r"), r.color[0]));
286        out.push((format!("{prefix}.color_g"), r.color[1]));
287        out.push((format!("{prefix}.color_b"), r.color[2]));
288        out.push((
289            format!("{prefix}.enabled"),
290            if r.enabled { 1.0 } else { 0.0 },
291        ));
292    }
293    out
294}
295
296#[allow(dead_code)]
297pub fn lod_density_factor(lod: u8) -> f32 {
298    match lod {
299        0 => 1.0,
300        1 => 0.4,
301        _ => 0.1,
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn test_default_profile_non_empty() {
311        let p = default_hair_profile();
312        assert!(!p.regions.is_empty());
313        assert!(p.regions.len() >= 8);
314    }
315
316    #[test]
317    fn test_default_profile_names() {
318        let p = default_hair_profile();
319        let names: Vec<&str> = p.regions.iter().map(|r| r.name.as_str()).collect();
320        assert!(names.contains(&"scalp"));
321        assert!(names.contains(&"beard"));
322    }
323
324    #[test]
325    fn test_hair_count_formula() {
326        let region = HairRegion {
327            name: "test".to_string(),
328            density: 100.0,
329            length: 10.0,
330            length_variance: 1.0,
331            curl: 0.0,
332            color: [0.0; 3],
333            enabled: true,
334        };
335        let count = hair_count_for_region(&region, 5.0);
336        assert_eq!(count, 500);
337    }
338
339    #[test]
340    fn test_hair_count_disabled() {
341        let region = HairRegion {
342            name: "test".to_string(),
343            density: 100.0,
344            length: 10.0,
345            length_variance: 1.0,
346            curl: 0.0,
347            color: [0.0; 3],
348            enabled: false,
349        };
350        assert_eq!(hair_count_for_region(&region, 10.0), 0);
351    }
352
353    #[test]
354    fn test_generate_strands_length() {
355        let region = HairRegion {
356            name: "test".to_string(),
357            density: 10.0,
358            length: 20.0,
359            length_variance: 2.0,
360            curl: 0.0,
361            color: [0.5, 0.4, 0.3],
362            enabled: true,
363        };
364        let positions: Vec<[f32; 3]> = (0..10).map(|i| [i as f32, 0.0, 0.0]).collect();
365        let normals: Vec<[f32; 3]> = (0..10).map(|_| [0.0, 1.0, 0.0]).collect();
366        let params = HairGenerationParams { seed: 42, lod: 0 };
367        let strands = generate_strands(&region, &positions, &normals, &params);
368        assert_eq!(strands.len(), 10);
369    }
370
371    #[test]
372    fn test_generate_strands_lod1() {
373        let region = HairRegion {
374            name: "test".to_string(),
375            density: 10.0,
376            length: 20.0,
377            length_variance: 2.0,
378            curl: 0.0,
379            color: [0.5, 0.4, 0.3],
380            enabled: true,
381        };
382        let positions: Vec<[f32; 3]> = (0..100).map(|i| [i as f32, 0.0, 0.0]).collect();
383        let normals: Vec<[f32; 3]> = (0..100).map(|_| [0.0, 1.0, 0.0]).collect();
384        let params = HairGenerationParams { seed: 42, lod: 1 };
385        let strands = generate_strands(&region, &positions, &normals, &params);
386        assert!(strands.len() < 100);
387    }
388
389    #[test]
390    fn test_generate_strands_disabled() {
391        let region = HairRegion {
392            name: "test".to_string(),
393            density: 10.0,
394            length: 20.0,
395            length_variance: 2.0,
396            curl: 0.0,
397            color: [0.5, 0.4, 0.3],
398            enabled: false,
399        };
400        let positions = vec![[0.0_f32; 3]];
401        let normals = vec![[0.0, 1.0, 0.0_f32]];
402        let params = HairGenerationParams { seed: 1, lod: 0 };
403        assert!(generate_strands(&region, &positions, &normals, &params).is_empty());
404    }
405
406    #[test]
407    fn test_blend_profiles() {
408        let a = default_hair_profile();
409        let b = default_hair_profile();
410        let blended = blend_hair_profiles(&a, &b, 0.5);
411        assert!(!blended.regions.is_empty());
412        assert!((blended.global_density_scale - 1.0).abs() < 1e-5);
413    }
414
415    #[test]
416    fn test_blend_profiles_t0() {
417        let a = default_hair_profile();
418        let mut b = default_hair_profile();
419        b.global_density_scale = 2.0;
420        let blended = blend_hair_profiles(&a, &b, 0.0);
421        assert!((blended.global_density_scale - 1.0).abs() < 1e-5);
422    }
423
424    #[test]
425    fn test_blend_profiles_t1() {
426        let a = default_hair_profile();
427        let mut b = default_hair_profile();
428        b.global_density_scale = 2.0;
429        let blended = blend_hair_profiles(&a, &b, 1.0);
430        assert!((blended.global_density_scale - 2.0).abs() < 1e-5);
431    }
432
433    #[test]
434    fn test_region_lookup() {
435        let p = default_hair_profile();
436        let r = region_by_name(&p, "scalp");
437        assert!(r.is_some());
438        assert_eq!(r.expect("should succeed").name, "scalp");
439    }
440
441    #[test]
442    fn test_region_lookup_missing() {
443        let p = default_hair_profile();
444        assert!(region_by_name(&p, "nonexistent").is_none());
445    }
446
447    #[test]
448    fn test_scale_density() {
449        let mut p = default_hair_profile();
450        scale_density(&mut p, 2.0);
451        assert!((p.global_density_scale - 2.0).abs() < 1e-5);
452    }
453
454    #[test]
455    fn test_lod_factor() {
456        assert!((lod_density_factor(0) - 1.0).abs() < 1e-5);
457        assert!((lod_density_factor(1) - 0.4).abs() < 1e-5);
458        assert!((lod_density_factor(2) - 0.1).abs() < 1e-5);
459        assert!((lod_density_factor(255) - 0.1).abs() < 1e-5);
460    }
461
462    #[test]
463    fn test_total_strand_count() {
464        let p = default_hair_profile();
465        let count = total_strand_count(&p, 1.0);
466        assert!(count > 0);
467    }
468
469    #[test]
470    fn test_hair_profile_to_params() {
471        let p = default_hair_profile();
472        let params = hair_profile_to_params(&p);
473        assert!(!params.is_empty());
474        let has_global = params.iter().any(|(k, _)| k == "global_density_scale");
475        assert!(has_global);
476    }
477
478    #[test]
479    fn test_curl_tip() {
480        let root = [0.0_f32; 3];
481        let normal = [0.0, 1.0, 0.0];
482        let tip = curl_tip(root, normal, 0.1, 0.0, 42);
483        assert!((tip[1] - 0.1).abs() < 0.01);
484    }
485
486    #[test]
487    fn test_add_region() {
488        let mut p = default_hair_profile();
489        let n = p.regions.len();
490        add_region(
491            &mut p,
492            HairRegion {
493                name: "extra".to_string(),
494                density: 5.0,
495                length: 5.0,
496                length_variance: 1.0,
497                curl: 0.0,
498                color: [1.0; 3],
499                enabled: true,
500            },
501        );
502        assert_eq!(p.regions.len(), n + 1);
503    }
504}