Skip to main content

oxihuman_morph/
procedural_wrinkle.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! Procedural wrinkle generator stub.
6
7/// Wrinkle pattern type.
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub enum WrinklePattern {
10    Linear,
11    Radial,
12    Noise,
13}
14
15/// A single procedural wrinkle region.
16#[derive(Debug, Clone)]
17pub struct WrinkleRegion {
18    pub pattern: WrinklePattern,
19    pub center: [f32; 3],
20    pub radius: f32,
21    pub amplitude: f32,
22    pub frequency: f32,
23    pub driver_weight: f32,
24}
25
26/// Procedural wrinkle generator.
27#[derive(Debug, Clone)]
28pub struct ProceduralWrinkle {
29    pub regions: Vec<WrinkleRegion>,
30    pub vertex_count: usize,
31    pub enabled: bool,
32    pub global_scale: f32,
33}
34
35impl ProceduralWrinkle {
36    pub fn new(vertex_count: usize) -> Self {
37        ProceduralWrinkle {
38            regions: Vec::new(),
39            vertex_count,
40            enabled: true,
41            global_scale: 1.0,
42        }
43    }
44}
45
46/// Create a new procedural wrinkle generator.
47pub fn new_procedural_wrinkle(vertex_count: usize) -> ProceduralWrinkle {
48    ProceduralWrinkle::new(vertex_count)
49}
50
51/// Add a wrinkle region.
52pub fn pw_add_region(pw: &mut ProceduralWrinkle, region: WrinkleRegion) {
53    pw.regions.push(region);
54}
55
56/// Evaluate wrinkle offsets with no vertex positions (returns all-zero offsets).
57///
58/// When vertex positions are not available use [`pw_evaluate_with_positions`]
59/// to obtain real wrinkle displacements.  This overload exists so that
60/// callers that have not yet been updated continue to compile and run.
61pub fn pw_evaluate(pw: &ProceduralWrinkle) -> Vec<[f32; 3]> {
62    vec![[0.0_f32; 3]; pw.vertex_count]
63}
64
65/// Evaluate procedural wrinkle offsets for a given set of vertex positions.
66///
67/// For each vertex the function accumulates displacement contributions from
68/// every [`WrinkleRegion`] whose sphere of influence covers that vertex:
69///
70/// 1. **Falloff** `t = 1 − dist/radius` (linear, 0 at the edge, 1 at the
71///    centre) weights each region's contribution.
72/// 2. **Pattern** determines the direction and shape of the displacement:
73///    - [`WrinklePattern::Linear`] — displaces along the region's local +X
74///      axis (world X) using a cosine ripple.
75///    - [`WrinklePattern::Radial`] — displaces outward along the
76///      centre → vertex direction using a cosine ripple.
77///    - [`WrinklePattern::Noise`] — displaces along the world +Y axis using
78///      a sine-based deterministic pseudo-noise pattern.
79/// 3. Each displacement is additionally scaled by `driver_weight` and the
80///    system-level `global_scale`.
81///
82/// `positions` must have the same length as `pw.vertex_count`.  If it is
83/// shorter the function processes only the available vertices; if it is
84/// longer only the first `vertex_count` entries are used.
85pub fn pw_evaluate_with_positions(pw: &ProceduralWrinkle, positions: &[[f32; 3]]) -> Vec<[f32; 3]> {
86    let count = pw.vertex_count.min(positions.len());
87    let mut offsets = vec![[0.0_f32; 3]; pw.vertex_count];
88
89    if !pw.enabled || pw.global_scale <= 0.0 {
90        return offsets;
91    }
92
93    for (v_idx, pos) in positions.iter().enumerate().take(count) {
94        let mut acc = [0.0_f32; 3];
95
96        for region in &pw.regions {
97            // Vector from region centre to vertex.
98            let dx = pos[0] - region.center[0];
99            let dy = pos[1] - region.center[1];
100            let dz = pos[2] - region.center[2];
101            let dist = (dx * dx + dy * dy + dz * dz).sqrt();
102
103            if dist > region.radius {
104                continue; // Outside influence sphere.
105            }
106
107            // Linear falloff: 1 at center, 0 at edge.
108            let t = 1.0_f32 - dist / region.radius;
109
110            // Cosine ripple magnitude (same formula for Linear and Radial).
111            let ripple = t * region.amplitude * (dist * region.frequency).cos();
112
113            let displacement: [f32; 3] = match region.pattern {
114                WrinklePattern::Linear => {
115                    // Displace along world +X axis.
116                    [ripple, 0.0_f32, 0.0_f32]
117                }
118                WrinklePattern::Radial => {
119                    // Displace along the centre → vertex direction.
120                    if dist < 1e-8 {
121                        [ripple, 0.0_f32, 0.0_f32]
122                    } else {
123                        let inv = 1.0_f32 / dist;
124                        [ripple * dx * inv, ripple * dy * inv, ripple * dz * inv]
125                    }
126                }
127                WrinklePattern::Noise => {
128                    // Deterministic sine-based pseudo-noise along world +Y axis.
129                    let noise = t * region.amplitude * (dist * region.frequency).sin();
130                    [0.0_f32, noise, 0.0_f32]
131                }
132            };
133
134            // Apply driver weight and global scale.
135            let scale = region.driver_weight * pw.global_scale;
136            acc[0] += displacement[0] * scale;
137            acc[1] += displacement[1] * scale;
138            acc[2] += displacement[2] * scale;
139        }
140
141        offsets[v_idx] = acc;
142    }
143
144    offsets
145}
146
147/// Set global scale.
148pub fn pw_set_global_scale(pw: &mut ProceduralWrinkle, scale: f32) {
149    pw.global_scale = scale.max(0.0);
150}
151
152/// Enable or disable.
153pub fn pw_set_enabled(pw: &mut ProceduralWrinkle, enabled: bool) {
154    pw.enabled = enabled;
155}
156
157/// Return region count.
158pub fn pw_region_count(pw: &ProceduralWrinkle) -> usize {
159    pw.regions.len()
160}
161
162/// Serialize to JSON-like string.
163pub fn pw_to_json(pw: &ProceduralWrinkle) -> String {
164    format!(
165        r#"{{"vertex_count":{},"region_count":{},"global_scale":{},"enabled":{}}}"#,
166        pw.vertex_count,
167        pw.regions.len(),
168        pw.global_scale,
169        pw.enabled
170    )
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn test_new_vertex_count() {
179        let pw = new_procedural_wrinkle(200);
180        assert_eq!(pw.vertex_count, 200 /* vertex count must match */,);
181    }
182
183    #[test]
184    fn test_no_regions_initially() {
185        let pw = new_procedural_wrinkle(100);
186        assert_eq!(pw_region_count(&pw), 0 /* no regions initially */,);
187    }
188
189    #[test]
190    fn test_add_region() {
191        let mut pw = new_procedural_wrinkle(100);
192        pw_add_region(
193            &mut pw,
194            WrinkleRegion {
195                pattern: WrinklePattern::Linear,
196                center: [0.0; 3],
197                radius: 1.0,
198                amplitude: 0.5,
199                frequency: 2.0,
200                driver_weight: 1.0,
201            },
202        );
203        assert_eq!(pw_region_count(&pw), 1 /* one region after add */,);
204    }
205
206    #[test]
207    fn test_evaluate_length() {
208        let pw = new_procedural_wrinkle(50);
209        let out = pw_evaluate(&pw);
210        assert_eq!(
211            out.len(),
212            50, /* output length must match vertex count */
213        );
214    }
215
216    #[test]
217    fn test_evaluate_zeroed() {
218        let pw = new_procedural_wrinkle(4);
219        let out = pw_evaluate(&pw);
220        assert!((out[0][0]).abs() < 1e-6 /* stub must return zeros */,);
221    }
222
223    #[test]
224    fn test_set_global_scale() {
225        let mut pw = new_procedural_wrinkle(10);
226        pw_set_global_scale(&mut pw, 2.5);
227        assert!((pw.global_scale - 2.5).abs() < 1e-5, /* scale must be set */);
228    }
229
230    #[test]
231    fn test_global_scale_clamped_negative() {
232        let mut pw = new_procedural_wrinkle(10);
233        pw_set_global_scale(&mut pw, -1.0);
234        assert!((pw.global_scale).abs() < 1e-6, /* negative scale clamped to 0 */);
235    }
236
237    #[test]
238    fn test_set_enabled() {
239        let mut pw = new_procedural_wrinkle(10);
240        pw_set_enabled(&mut pw, false);
241        assert!(!pw.enabled /* must be disabled */,);
242    }
243
244    #[test]
245    fn test_to_json_contains_vertex_count() {
246        let pw = new_procedural_wrinkle(30);
247        let j = pw_to_json(&pw);
248        assert!(j.contains("\"vertex_count\""), /* json must contain vertex_count */);
249    }
250
251    #[test]
252    fn test_enabled_default() {
253        let pw = new_procedural_wrinkle(1);
254        assert!(pw.enabled /* must be enabled by default */,);
255    }
256
257    #[test]
258    fn pw_evaluate_region_affects_nearby_vertex() {
259        // A radial region at the origin with radius 1.0 should produce a
260        // non-zero offset for a vertex at [0.5, 0, 0] which lies inside it.
261        let mut pw = new_procedural_wrinkle(1);
262        pw_add_region(
263            &mut pw,
264            WrinkleRegion {
265                pattern: WrinklePattern::Radial,
266                center: [0.0, 0.0, 0.0],
267                radius: 1.0,
268                amplitude: 1.0,
269                frequency: 1.0,
270                driver_weight: 1.0,
271            },
272        );
273        let positions: Vec<[f32; 3]> = vec![[0.5, 0.0, 0.0]];
274        let offsets = pw_evaluate_with_positions(&pw, &positions);
275        let mag = offsets[0].iter().map(|&v| v * v).sum::<f32>().sqrt();
276        assert!(
277            mag > 1e-6,
278            "vertex inside region must receive a non-zero offset, got magnitude {mag}"
279        );
280    }
281
282    #[test]
283    fn pw_evaluate_vertex_outside_region_zero() {
284        // A vertex at [2.0, 0, 0] is outside a region of radius 1.0 centred
285        // at the origin and must receive exactly zero offset.
286        let mut pw = new_procedural_wrinkle(1);
287        pw_add_region(
288            &mut pw,
289            WrinkleRegion {
290                pattern: WrinklePattern::Radial,
291                center: [0.0, 0.0, 0.0],
292                radius: 1.0,
293                amplitude: 1.0,
294                frequency: 1.0,
295                driver_weight: 1.0,
296            },
297        );
298        let positions: Vec<[f32; 3]> = vec![[2.0, 0.0, 0.0]];
299        let offsets = pw_evaluate_with_positions(&pw, &positions);
300        let mag = offsets[0].iter().map(|&v| v * v).sum::<f32>().sqrt();
301        assert!(
302            mag < 1e-6,
303            "vertex outside region must have zero offset, got magnitude {mag}"
304        );
305    }
306}