Skip to main content

uzor_interactive/
electric_border.rs

1//! Electric border animation with perlin-like noise displacement
2//!
3//! Animates a border path with randomized displacement along the perimeter,
4//! creating an electric/lightning effect. Uses multi-octave noise for
5//! organic-looking animation.
6
7use std::f32::consts::PI;
8
9/// Electric border animator
10///
11/// Generates displaced points along a rounded rectangle border path,
12/// creating an animated electric effect using noise displacement.
13#[derive(Debug, Clone)]
14pub struct ElectricBorder {
15    /// Border width in pixels
16    pub width: f32,
17
18    /// Border height in pixels
19    pub height: f32,
20
21    /// Border radius for rounded corners (pixels)
22    pub border_radius: f32,
23
24    /// Animation speed multiplier (1.0 = normal)
25    pub speed: f32,
26
27    /// Chaos level - displacement amplitude (0.0 = none, 0.2 = high)
28    pub chaos: f32,
29
30    /// Current animation time (seconds)
31    time: f32,
32
33    /// Number of sample points along border
34    sample_count: usize,
35
36    /// Displacement scale in pixels
37    displacement: f32,
38}
39
40impl Default for ElectricBorder {
41    fn default() -> Self {
42        Self::new(400.0, 300.0)
43    }
44}
45
46impl ElectricBorder {
47    /// Create a new electric border
48    pub fn new(width: f32, height: f32) -> Self {
49        let perimeter = 2.0 * (width + height);
50        let sample_count = (perimeter / 2.0) as usize;
51
52        Self {
53            width,
54            height,
55            border_radius: 24.0,
56            speed: 1.0,
57            chaos: 0.12,
58            time: 0.0,
59            sample_count,
60            displacement: 60.0,
61        }
62    }
63
64    /// Set border radius
65    pub fn with_radius(mut self, radius: f32) -> Self {
66        self.border_radius = radius;
67        self
68    }
69
70    /// Set animation speed
71    pub fn with_speed(mut self, speed: f32) -> Self {
72        self.speed = speed;
73        self
74    }
75
76    /// Set chaos level (displacement amount)
77    pub fn with_chaos(mut self, chaos: f32) -> Self {
78        self.chaos = chaos;
79        self
80    }
81
82    /// Update dimensions
83    pub fn set_dimensions(&mut self, width: f32, height: f32) {
84        self.width = width;
85        self.height = height;
86
87        // Recalculate sample count based on perimeter
88        let perimeter = 2.0 * (width + height);
89        self.sample_count = (perimeter / 2.0) as usize;
90    }
91
92    /// Update animation time
93    pub fn update(&mut self, delta_time: f32) {
94        self.time += delta_time * self.speed;
95    }
96
97    /// Set absolute time
98    pub fn set_time(&mut self, time: f32) {
99        self.time = time;
100    }
101
102    /// Get current animation time
103    pub fn time(&self) -> f32 {
104        self.time
105    }
106
107    /// Generate displaced border points
108    ///
109    /// Returns a vector of (x, y) coordinates along the border path
110    /// with noise-based displacement applied.
111    pub fn generate_points(&self) -> Vec<(f32, f32)> {
112        let mut points = Vec::with_capacity(self.sample_count + 1);
113
114        let max_radius = (self.width.min(self.height) / 2.0).min(self.border_radius);
115        let radius = max_radius.max(0.0);
116
117        for i in 0..=self.sample_count {
118            let t = i as f32 / self.sample_count as f32;
119
120            // Get base point on rounded rectangle
121            let (base_x, base_y) = self.get_rounded_rect_point(t, radius);
122
123            // Apply noise displacement
124            let (dx, dy) = self.get_displacement(t);
125
126            points.push((base_x + dx, base_y + dy));
127        }
128
129        points
130    }
131
132    /// Get point on rounded rectangle perimeter at parameter t (0.0 to 1.0)
133    fn get_rounded_rect_point(&self, t: f32, radius: f32) -> (f32, f32) {
134        let straight_width = self.width - 2.0 * radius;
135        let straight_height = self.height - 2.0 * radius;
136        let corner_arc = (PI * radius) / 2.0;
137
138        let total_perimeter =
139            2.0 * straight_width + 2.0 * straight_height + 4.0 * corner_arc;
140
141        let distance = t * total_perimeter;
142        let mut accumulated = 0.0;
143
144        // Top edge
145        if distance <= accumulated + straight_width {
146            let progress = (distance - accumulated) / straight_width;
147            return (radius + progress * straight_width, 0.0);
148        }
149        accumulated += straight_width;
150
151        // Top-right corner
152        if distance <= accumulated + corner_arc {
153            let progress = (distance - accumulated) / corner_arc;
154            return self.get_corner_point(
155                self.width - radius,
156                radius,
157                radius,
158                -PI / 2.0,
159                progress,
160            );
161        }
162        accumulated += corner_arc;
163
164        // Right edge
165        if distance <= accumulated + straight_height {
166            let progress = (distance - accumulated) / straight_height;
167            return (self.width, radius + progress * straight_height);
168        }
169        accumulated += straight_height;
170
171        // Bottom-right corner
172        if distance <= accumulated + corner_arc {
173            let progress = (distance - accumulated) / corner_arc;
174            return self.get_corner_point(
175                self.width - radius,
176                self.height - radius,
177                radius,
178                0.0,
179                progress,
180            );
181        }
182        accumulated += corner_arc;
183
184        // Bottom edge
185        if distance <= accumulated + straight_width {
186            let progress = (distance - accumulated) / straight_width;
187            return (
188                self.width - radius - progress * straight_width,
189                self.height,
190            );
191        }
192        accumulated += straight_width;
193
194        // Bottom-left corner
195        if distance <= accumulated + corner_arc {
196            let progress = (distance - accumulated) / corner_arc;
197            return self.get_corner_point(
198                radius,
199                self.height - radius,
200                radius,
201                PI / 2.0,
202                progress,
203            );
204        }
205        accumulated += corner_arc;
206
207        // Left edge
208        if distance <= accumulated + straight_height {
209            let progress = (distance - accumulated) / straight_height;
210            return (0.0, self.height - radius - progress * straight_height);
211        }
212        accumulated += straight_height;
213
214        // Top-left corner (remaining)
215        let progress = (distance - accumulated) / corner_arc;
216        self.get_corner_point(radius, radius, radius, PI, progress)
217    }
218
219    /// Get point on circular arc
220    fn get_corner_point(
221        &self,
222        center_x: f32,
223        center_y: f32,
224        radius: f32,
225        start_angle: f32,
226        progress: f32,
227    ) -> (f32, f32) {
228        let angle = start_angle + progress * (PI / 2.0);
229        (
230            center_x + radius * angle.cos(),
231            center_y + radius * angle.sin(),
232        )
233    }
234
235    /// Get noise displacement at parameter t
236    fn get_displacement(&self, t: f32) -> (f32, f32) {
237        let octaves = 10;
238        let lacunarity = 1.6;
239        let gain = 0.7;
240        let amplitude = self.chaos;
241        let frequency = 10.0;
242
243        let x_noise = self.octaved_noise(
244            t * 8.0,
245            octaves,
246            lacunarity,
247            gain,
248            amplitude,
249            frequency,
250            self.time,
251            0.0,
252        );
253
254        let y_noise = self.octaved_noise(
255            t * 8.0,
256            octaves,
257            lacunarity,
258            gain,
259            amplitude,
260            frequency,
261            self.time,
262            1.0,
263        );
264
265        let scale = self.displacement;
266        (x_noise * scale, y_noise * scale)
267    }
268
269    /// Multi-octave noise function
270    #[allow(clippy::too_many_arguments)]
271    fn octaved_noise(
272        &self,
273        x: f32,
274        octaves: usize,
275        lacunarity: f32,
276        gain: f32,
277        base_amplitude: f32,
278        base_frequency: f32,
279        time: f32,
280        seed: f32,
281    ) -> f32 {
282        let mut result = 0.0;
283        let mut amplitude = base_amplitude;
284        let mut frequency = base_frequency;
285
286        for _i in 0..octaves {
287            result += amplitude
288                * self.noise_2d(
289                    frequency * x + seed * 100.0,
290                    time * frequency * 0.3,
291                );
292            frequency *= lacunarity;
293            amplitude *= gain;
294        }
295
296        result
297    }
298
299    /// 2D noise function (simplified perlin-style)
300    fn noise_2d(&self, x: f32, y: f32) -> f32 {
301        let i = x.floor();
302        let j = y.floor();
303        let fx = x - i;
304        let fy = y - j;
305
306        // Sample grid corners
307        let a = self.random(i + j * 57.0);
308        let b = self.random(i + 1.0 + j * 57.0);
309        let c = self.random(i + (j + 1.0) * 57.0);
310        let d = self.random(i + 1.0 + (j + 1.0) * 57.0);
311
312        // Smoothstep interpolation
313        let ux = fx * fx * (3.0 - 2.0 * fx);
314        let uy = fy * fy * (3.0 - 2.0 * fy);
315
316        // Bilinear interpolation
317        a * (1.0 - ux) * (1.0 - uy)
318            + b * ux * (1.0 - uy)
319            + c * (1.0 - ux) * uy
320            + d * ux * uy
321    }
322
323    /// Pseudo-random function
324    fn random(&self, x: f32) -> f32 {
325        ((x * 12.9898).sin() * 43_758.547) % 1.0
326    }
327
328    /// Get number of sample points
329    pub fn sample_count(&self) -> usize {
330        self.sample_count
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn test_electric_border_creation() {
340        let border = ElectricBorder::new(400.0, 300.0);
341        assert_eq!(border.width, 400.0);
342        assert_eq!(border.height, 300.0);
343        assert!(border.sample_count > 0);
344    }
345
346    #[test]
347    fn test_time_update() {
348        let mut border = ElectricBorder::new(400.0, 300.0);
349
350        border.update(0.1);
351        assert!((border.time() - 0.1).abs() < 0.01);
352
353        border.update(0.1);
354        assert!((border.time() - 0.2).abs() < 0.01);
355    }
356
357    #[test]
358    fn test_speed_multiplier() {
359        let mut border = ElectricBorder::new(400.0, 300.0).with_speed(2.0);
360
361        border.update(0.1);
362        assert!((border.time() - 0.2).abs() < 0.01); // 0.1 * 2.0
363    }
364
365    #[test]
366    fn test_generate_points() {
367        let border = ElectricBorder::new(400.0, 300.0);
368        let points = border.generate_points();
369
370        // Should have sample_count + 1 points (including closing point)
371        assert_eq!(points.len(), border.sample_count() + 1);
372
373        // Points should be roughly within bounds (plus displacement margin)
374        for (x, y) in points.iter() {
375            assert!(
376                x >= &-border.displacement && x <= &(border.width + border.displacement)
377            );
378            assert!(
379                y >= &-border.displacement && y <= &(border.height + border.displacement)
380            );
381        }
382    }
383
384    #[test]
385    fn test_rounded_rect_corners() {
386        let border = ElectricBorder::new(400.0, 300.0).with_radius(24.0);
387
388        // Test corner points (approximately)
389        let radius = border.border_radius;
390
391        // Top-left corner region (t near 0)
392        let (x, y) = border.get_rounded_rect_point(0.0, radius);
393        assert!(x >= radius - 1.0 && x <= radius + 1.0);
394        assert!(y < 1.0);
395
396        // Closing point should match start
397        let (x_end, y_end) = border.get_rounded_rect_point(1.0, radius);
398        assert!((x - x_end).abs() < 10.0);
399        assert!((y - y_end).abs() < 10.0);
400    }
401
402    #[test]
403    fn test_noise_consistency() {
404        let border = ElectricBorder::new(400.0, 300.0);
405
406        // Same input should produce same output
407        let noise1 = border.noise_2d(1.5, 2.5);
408        let noise2 = border.noise_2d(1.5, 2.5);
409        assert_eq!(noise1, noise2);
410    }
411
412    #[test]
413    fn test_random_function() {
414        let border = ElectricBorder::new(400.0, 300.0);
415
416        // Random should be deterministic
417        let r1 = border.random(42.0);
418        let r2 = border.random(42.0);
419        assert_eq!(r1, r2);
420
421        // Different inputs should give different outputs
422        let r3 = border.random(43.0);
423        assert_ne!(r1, r3);
424    }
425
426    #[test]
427    fn test_dimensions_update() {
428        let mut border = ElectricBorder::new(400.0, 300.0);
429        let old_count = border.sample_count();
430
431        border.set_dimensions(800.0, 600.0);
432        assert_eq!(border.width, 800.0);
433        assert_eq!(border.height, 600.0);
434
435        // Sample count should increase with perimeter
436        assert!(border.sample_count() > old_count);
437    }
438
439    #[test]
440    fn test_displacement_changes_over_time() {
441        let mut border = ElectricBorder::new(400.0, 300.0);
442
443        let points_t0 = border.generate_points();
444
445        border.update(1.0);
446        let points_t1 = border.generate_points();
447
448        // Points should be different due to time-varying noise
449        let mut different = false;
450        for i in 0..points_t0.len().min(points_t1.len()) {
451            if (points_t0[i].0 - points_t1[i].0).abs() > 0.1
452                || (points_t0[i].1 - points_t1[i].1).abs() > 0.1
453            {
454                different = true;
455                break;
456            }
457        }
458        assert!(different, "Points should change over time");
459    }
460}