Skip to main content

oxihuman_morph/
hair_strand.rs

1//! Hair strand simulation morphs for procedural hair control.
2
3// ---------------------------------------------------------------------------
4// Types
5// ---------------------------------------------------------------------------
6
7/// Hair profile categories.
8#[allow(dead_code)]
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum HairProfile {
11    Straight,
12    Wavy,
13    Curly,
14    Coily,
15}
16
17/// Configuration for hair strand generation.
18#[allow(dead_code)]
19#[derive(Clone, Debug)]
20pub struct HairStrandConfig {
21    /// Number of points per strand.
22    pub point_count: usize,
23    /// Default strand length.
24    pub length: f32,
25    /// Gravity strength.
26    pub gravity: f32,
27    /// Curl frequency for wavy/curly/coily profiles.
28    pub curl_freq: f32,
29    /// Curl amplitude for wavy/curly/coily profiles.
30    pub curl_amp: f32,
31}
32
33/// A single hair strand represented as a sequence of 3D points.
34#[allow(dead_code)]
35#[derive(Clone, Debug)]
36pub struct HairStrand {
37    /// Points along the strand: `[x, y, z, x, y, z, ...]`.
38    pub points: Vec<f32>,
39    /// Hair profile type.
40    pub profile: HairProfile,
41    /// Original strand length (sum of segment lengths at generation).
42    pub rest_length: f32,
43}
44
45/// Axis-aligned bounding box result.
46#[allow(dead_code)]
47pub type BoundingBox = ([f32; 3], [f32; 3]);
48
49/// Simple LCG pseudo-random number generator (no external crate).
50struct Lcg(u64);
51
52impl Lcg {
53    fn new(seed: u64) -> Self {
54        Self(seed.wrapping_add(1))
55    }
56
57    fn next_u64(&mut self) -> u64 {
58        self.0 = self
59            .0
60            .wrapping_mul(6364136223846793005)
61            .wrapping_add(1442695040888963407);
62        self.0
63    }
64
65    fn next_f32(&mut self) -> f32 {
66        (self.next_u64() >> 11) as f32 / (1u64 << 53) as f32
67    }
68}
69
70// ---------------------------------------------------------------------------
71// Construction
72// ---------------------------------------------------------------------------
73
74/// Create a default strand configuration.
75#[allow(dead_code)]
76pub fn default_strand_config() -> HairStrandConfig {
77    HairStrandConfig {
78        point_count: 16,
79        length: 0.3,
80        gravity: 9.81,
81        curl_freq: 4.0,
82        curl_amp: 0.01,
83    }
84}
85
86/// Create a new hair strand with no points.
87#[allow(dead_code)]
88pub fn new_hair_strand(profile: HairProfile) -> HairStrand {
89    HairStrand {
90        points: Vec::new(),
91        profile,
92        rest_length: 0.0,
93    }
94}
95
96// ---------------------------------------------------------------------------
97// Generation
98// ---------------------------------------------------------------------------
99
100/// Generate strand points from a root position, direction, and profile.
101/// Uses an LCG seeded by the root position hash to add curl variation.
102#[allow(dead_code)]
103pub fn generate_strand_points(
104    root: [f32; 3],
105    direction: [f32; 3],
106    config: &HairStrandConfig,
107    profile: HairProfile,
108) -> HairStrand {
109    let n = config.point_count.max(2);
110    let seg_len = config.length / (n - 1) as f32;
111
112    // Normalize direction
113    let dlen =
114        (direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2])
115            .sqrt()
116            .max(1e-12);
117    let dir = [
118        direction[0] / dlen,
119        direction[1] / dlen,
120        direction[2] / dlen,
121    ];
122
123    // Build a tangent for curl offset
124    let tangent = if dir[1].abs() < 0.9 {
125        // cross(dir, up)
126        let cx = dir[2];
127        let cy = 0.0;
128        let cz = -dir[0];
129        let cl = (cx * cx + cy * cy + cz * cz).sqrt().max(1e-12);
130        [cx / cl, cy / cl, cz / cl]
131    } else {
132        let cx = 0.0;
133        let cy = -dir[2];
134        let cz = dir[1];
135        let cl = (cx * cx + cy * cy + cz * cz).sqrt().max(1e-12);
136        [cx / cl, cy / cl, cz / cl]
137    };
138
139    let (freq, amp) = curl_params(profile, config);
140
141    let seed = (root[0].to_bits() as u64)
142        .wrapping_add(root[1].to_bits() as u64)
143        .wrapping_add(root[2].to_bits() as u64);
144    let mut rng = Lcg::new(seed);
145    let phase = rng.next_f32() * std::f32::consts::TAU;
146
147    let mut points = Vec::with_capacity(n * 3);
148    let mut px = root[0];
149    let mut py = root[1];
150    let mut pz = root[2];
151    points.push(px);
152    points.push(py);
153    points.push(pz);
154
155    for i in 1..n {
156        let t = i as f32 / (n - 1) as f32;
157        let angle = t * freq * std::f32::consts::TAU + phase;
158        let curl_offset = angle.sin() * amp;
159
160        px += dir[0] * seg_len + tangent[0] * curl_offset;
161        py += dir[1] * seg_len + tangent[1] * curl_offset;
162        pz += dir[2] * seg_len + tangent[2] * curl_offset;
163
164        points.push(px);
165        points.push(py);
166        points.push(pz);
167    }
168
169    let rest = compute_rest_length(&points);
170
171    HairStrand {
172        points,
173        profile,
174        rest_length: rest,
175    }
176}
177
178fn curl_params(profile: HairProfile, config: &HairStrandConfig) -> (f32, f32) {
179    match profile {
180        HairProfile::Straight => (0.0, 0.0),
181        HairProfile::Wavy => (config.curl_freq * 0.5, config.curl_amp * 0.5),
182        HairProfile::Curly => (config.curl_freq, config.curl_amp),
183        HairProfile::Coily => (config.curl_freq * 2.0, config.curl_amp * 1.5),
184    }
185}
186
187fn compute_rest_length(points: &[f32]) -> f32 {
188    let n = points.len() / 3;
189    if n < 2 {
190        return 0.0;
191    }
192    let mut total = 0.0_f32;
193    for i in 1..n {
194        let dx = points[i * 3] - points[(i - 1) * 3];
195        let dy = points[i * 3 + 1] - points[(i - 1) * 3 + 1];
196        let dz = points[i * 3 + 2] - points[(i - 1) * 3 + 2];
197        total += (dx * dx + dy * dy + dz * dz).sqrt();
198    }
199    total
200}
201
202// ---------------------------------------------------------------------------
203// Physics / Modification
204// ---------------------------------------------------------------------------
205
206/// Apply gravity to a strand, displacing each point downward proportional
207/// to its distance from root.
208#[allow(dead_code)]
209pub fn apply_gravity_to_strand(strand: &mut HairStrand, gravity: f32, dt: f32) {
210    let n = strand.points.len() / 3;
211    if n < 2 {
212        return;
213    }
214    for i in 1..n {
215        let factor = i as f32 / (n - 1) as f32;
216        strand.points[i * 3 + 1] -= gravity * dt * factor;
217    }
218}
219
220// ---------------------------------------------------------------------------
221// Queries
222// ---------------------------------------------------------------------------
223
224/// Return the total length of the strand (sum of segment lengths).
225#[allow(dead_code)]
226pub fn strand_length(strand: &HairStrand) -> f32 {
227    compute_rest_length(&strand.points)
228}
229
230/// Return the number of control points in the strand.
231#[allow(dead_code)]
232pub fn strand_point_count(strand: &HairStrand) -> usize {
233    strand.points.len() / 3
234}
235
236/// Set the strand's profile type.
237#[allow(dead_code)]
238pub fn set_strand_profile(strand: &mut HairStrand, profile: HairProfile) {
239    strand.profile = profile;
240}
241
242/// Return the curl frequency for a given profile using the supplied config.
243#[allow(dead_code)]
244pub fn curl_frequency(profile: HairProfile, config: &HairStrandConfig) -> f32 {
245    curl_params(profile, config).0
246}
247
248/// Return the curl amplitude for a given profile using the supplied config.
249#[allow(dead_code)]
250pub fn curl_amplitude(profile: HairProfile, config: &HairStrandConfig) -> f32 {
251    curl_params(profile, config).1
252}
253
254/// Compute the tangent vector at parameter `t` (0..1) along the strand.
255/// Returns `[0, 0, 0]` for empty or single-point strands.
256#[allow(dead_code)]
257pub fn strand_tangent_at(strand: &HairStrand, t: f32) -> [f32; 3] {
258    let n = strand.points.len() / 3;
259    if n < 2 {
260        return [0.0, 0.0, 0.0];
261    }
262    let t = t.clamp(0.0, 1.0);
263    let seg = (t * (n - 1) as f32).min((n - 2) as f32);
264    let idx = seg as usize;
265    let dx = strand.points[(idx + 1) * 3] - strand.points[idx * 3];
266    let dy = strand.points[(idx + 1) * 3 + 1] - strand.points[idx * 3 + 1];
267    let dz = strand.points[(idx + 1) * 3 + 2] - strand.points[idx * 3 + 2];
268    let len = (dx * dx + dy * dy + dz * dz).sqrt().max(1e-12);
269    [dx / len, dy / len, dz / len]
270}
271
272/// Blend two strands point-by-point. If they have different point counts,
273/// use the shorter count.
274#[allow(dead_code)]
275pub fn blend_strands(a: &HairStrand, b: &HairStrand, t: f32) -> HairStrand {
276    let t = t.clamp(0.0, 1.0);
277    let count = a.points.len().min(b.points.len());
278    let mut points = Vec::with_capacity(count);
279    for i in 0..count {
280        points.push(a.points[i] + (b.points[i] - a.points[i]) * t);
281    }
282    let profile = if t < 0.5 { a.profile } else { b.profile };
283    let rest = compute_rest_length(&points);
284    HairStrand {
285        points,
286        profile,
287        rest_length: rest,
288    }
289}
290
291/// Compute an axis-aligned bounding box for the strand.
292/// Returns `([min_x, min_y, min_z], [max_x, max_y, max_z])`.
293/// Returns zeros for empty strands.
294#[allow(dead_code)]
295pub fn strand_bounding_box(strand: &HairStrand) -> BoundingBox {
296    let n = strand.points.len() / 3;
297    if n == 0 {
298        return ([0.0; 3], [0.0; 3]);
299    }
300    let mut min = [strand.points[0], strand.points[1], strand.points[2]];
301    let mut max = min;
302    for i in 1..n {
303        for j in 0..3 {
304            let v = strand.points[i * 3 + j];
305            if v < min[j] {
306                min[j] = v;
307            }
308            if v > max[j] {
309                max[j] = v;
310            }
311        }
312    }
313    (min, max)
314}
315
316/// Convert strand points to a flat vertex buffer (just returns a clone of
317/// the internal points array for now; a production version would add normals).
318#[allow(dead_code)]
319pub fn strand_to_vertices(strand: &HairStrand) -> Vec<f32> {
320    strand.points.clone()
321}
322
323// ---------------------------------------------------------------------------
324// Tests
325// ---------------------------------------------------------------------------
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn test_default_config() {
333        let cfg = default_strand_config();
334        assert!(cfg.point_count >= 2);
335        assert!(cfg.length > 0.0);
336    }
337
338    #[test]
339    fn test_new_strand_empty() {
340        let s = new_hair_strand(HairProfile::Straight);
341        assert!(s.points.is_empty());
342        assert_eq!(s.profile, HairProfile::Straight);
343    }
344
345    #[test]
346    fn test_generate_straight() {
347        let cfg = default_strand_config();
348        let s = generate_strand_points(
349            [0.0, 0.0, 0.0],
350            [0.0, -1.0, 0.0],
351            &cfg,
352            HairProfile::Straight,
353        );
354        assert_eq!(strand_point_count(&s), cfg.point_count);
355        assert!(strand_length(&s) > 0.0);
356    }
357
358    #[test]
359    fn test_generate_curly() {
360        let cfg = default_strand_config();
361        let s = generate_strand_points([0.0, 0.0, 0.0], [0.0, -1.0, 0.0], &cfg, HairProfile::Curly);
362        assert_eq!(strand_point_count(&s), cfg.point_count);
363    }
364
365    #[test]
366    fn test_generate_coily() {
367        let cfg = default_strand_config();
368        let s = generate_strand_points([0.0, 0.0, 0.0], [0.0, -1.0, 0.0], &cfg, HairProfile::Coily);
369        assert_eq!(strand_point_count(&s), cfg.point_count);
370    }
371
372    #[test]
373    fn test_gravity_displaces_downward() {
374        let cfg = default_strand_config();
375        let mut s = generate_strand_points(
376            [0.0, 1.0, 0.0],
377            [0.0, -1.0, 0.0],
378            &cfg,
379            HairProfile::Straight,
380        );
381        let y_before = s.points[s.points.len() - 2]; // last point Y
382        apply_gravity_to_strand(&mut s, 9.81, 0.1);
383        let y_after = s.points[s.points.len() - 2];
384        assert!(y_after < y_before);
385    }
386
387    #[test]
388    fn test_strand_length_positive() {
389        let cfg = default_strand_config();
390        let s = generate_strand_points(
391            [0.0, 0.0, 0.0],
392            [0.0, -1.0, 0.0],
393            &cfg,
394            HairProfile::Straight,
395        );
396        assert!(strand_length(&s) > 0.0);
397    }
398
399    #[test]
400    fn test_strand_point_count() {
401        let cfg = default_strand_config();
402        let s = generate_strand_points([0.0, 0.0, 0.0], [0.0, -1.0, 0.0], &cfg, HairProfile::Wavy);
403        assert_eq!(strand_point_count(&s), cfg.point_count);
404    }
405
406    #[test]
407    fn test_set_profile() {
408        let mut s = new_hair_strand(HairProfile::Straight);
409        set_strand_profile(&mut s, HairProfile::Curly);
410        assert_eq!(s.profile, HairProfile::Curly);
411    }
412
413    #[test]
414    fn test_curl_frequency_straight_zero() {
415        let cfg = default_strand_config();
416        assert_eq!(curl_frequency(HairProfile::Straight, &cfg), 0.0);
417    }
418
419    #[test]
420    fn test_curl_amplitude_curly_positive() {
421        let cfg = default_strand_config();
422        assert!(curl_amplitude(HairProfile::Curly, &cfg) > 0.0);
423    }
424
425    #[test]
426    fn test_tangent_at_start() {
427        let cfg = default_strand_config();
428        let s = generate_strand_points(
429            [0.0, 0.0, 0.0],
430            [0.0, -1.0, 0.0],
431            &cfg,
432            HairProfile::Straight,
433        );
434        let tan = strand_tangent_at(&s, 0.0);
435        // Should roughly point downward
436        assert!(tan[1] < 0.0);
437    }
438
439    #[test]
440    fn test_tangent_empty_strand() {
441        let s = new_hair_strand(HairProfile::Straight);
442        let tan = strand_tangent_at(&s, 0.5);
443        assert_eq!(tan, [0.0, 0.0, 0.0]);
444    }
445
446    #[test]
447    fn test_blend_strands_at_zero() {
448        let cfg = default_strand_config();
449        let a = generate_strand_points(
450            [0.0, 0.0, 0.0],
451            [0.0, -1.0, 0.0],
452            &cfg,
453            HairProfile::Straight,
454        );
455        let b = generate_strand_points([1.0, 0.0, 0.0], [0.0, -1.0, 0.0], &cfg, HairProfile::Curly);
456        let c = blend_strands(&a, &b, 0.0);
457        assert_eq!(c.points[0], a.points[0]);
458        assert_eq!(c.profile, HairProfile::Straight);
459    }
460
461    #[test]
462    fn test_bounding_box_non_empty() {
463        let cfg = default_strand_config();
464        let s = generate_strand_points(
465            [0.0, 0.0, 0.0],
466            [0.0, -1.0, 0.0],
467            &cfg,
468            HairProfile::Straight,
469        );
470        let (min, max) = strand_bounding_box(&s);
471        assert!(max[1] >= min[1]);
472    }
473
474    #[test]
475    fn test_bounding_box_empty() {
476        let s = new_hair_strand(HairProfile::Straight);
477        let (min, max) = strand_bounding_box(&s);
478        assert_eq!(min, [0.0, 0.0, 0.0]);
479        assert_eq!(max, [0.0, 0.0, 0.0]);
480    }
481
482    #[test]
483    fn test_strand_to_vertices() {
484        let cfg = default_strand_config();
485        let s = generate_strand_points(
486            [0.0, 0.0, 0.0],
487            [0.0, -1.0, 0.0],
488            &cfg,
489            HairProfile::Straight,
490        );
491        let verts = strand_to_vertices(&s);
492        assert_eq!(verts.len(), s.points.len());
493    }
494}