Skip to main content

egui_elements/widgets/particles/lightning/
effect.rs

1//! Lightning bolts and a full-area flash for egui.
2
3use egui::{Color32, Context, Id, LayerId, Order, Pos2, Rect, Shape, Stroke, Vec2, vec2};
4use std::time::Duration;
5
6use super::super::shared::{ParticleLayer, ParticleRng, resolve_target};
7use super::settings::LightningSettings;
8
9/// Recursion levels used to displace a bolt's main trunk.
10const BOLT_LEVELS: u32 = 6;
11/// Deepest branch recursion the settings may ask for (bounds the recursion).
12const MAX_BRANCH_DEPTH: u32 = 8;
13/// Horizontal spawn range of a bolt's endpoints in normalized space.
14const BOLT_X_MIN: f32 = 0.15;
15/// Horizontal spawn range of a bolt's endpoints in normalized space.
16const BOLT_X_MAX: f32 = 0.85;
17
18/// Fraction of the parent a branch may attach at (lower end).
19const BRANCH_START_MIN: f32 = 0.45;
20/// Fraction of the parent a branch may attach at (upper end).
21const BRANCH_START_MAX: f32 = 0.95;
22/// Branch length as a fraction of its parent's length (shortest).
23const BRANCH_LENGTH_MIN: f32 = 0.25;
24/// Branch length as a fraction of its parent's length (longest).
25const BRANCH_LENGTH_MAX: f32 = 0.55;
26/// Smallest angle a branch leaves its parent at, in radians.
27const BRANCH_ANGLE_MIN: f32 = 0.5;
28/// Largest angle a branch leaves its parent at, in radians.
29const BRANCH_ANGLE_MAX: f32 = 1.2;
30
31/// Start of each sub-flash, as a fraction of the strike duration.
32const SUB_FLASH_STARTS: [f32; 4] = [0.0, 0.12, 0.22, 0.35];
33/// Relative height of each sub-flash before the per-strike jitter.
34const SUB_FLASH_HEIGHTS: [f32; 4] = [1.0, 0.55, 0.8, 0.35];
35/// Fast attack of a sub-flash, as a fraction of the strike duration.
36const SUB_FLASH_ATTACK: f32 = 0.02;
37/// Exponential decay time constant of a sub-flash, as a fraction of the duration.
38const SUB_FLASH_DECAY: f32 = 0.08;
39/// Lower bound of the per-strike sub-flash height jitter.
40const SUB_FLASH_JITTER_MIN: f32 = 0.7;
41/// Upper bound of the per-strike sub-flash height jitter.
42const SUB_FLASH_JITTER_MAX: f32 = 1.0;
43
44/// Halo stroke width as a multiple of the core thickness.
45const HALO_WIDTH: f32 = 6.0;
46/// Halo stroke alpha at a full envelope.
47const HALO_ALPHA: f32 = 0.10;
48/// Mid stroke width as a multiple of the core thickness.
49const MID_WIDTH: f32 = 2.5;
50/// Mid stroke alpha at a full envelope.
51const MID_ALPHA: f32 = 0.35;
52
53/// Random spread of the ambient interval: ±50 %.
54const AMBIENT_JITTER: f32 = 0.5;
55
56/// Default bolt colour: a cold white-blue.
57pub(super) const DEFAULT_COLOR: Color32 = Color32::from_rgb(190, 215, 255);
58/// Default number of bolts per strike.
59pub(super) const DEFAULT_BOLT_COUNT: usize = 1;
60/// Default branch recursion depth.
61pub(super) const DEFAULT_BRANCH_DEPTH: u32 = 2;
62/// Default core stroke thickness in pixels.
63pub(super) const DEFAULT_THICKNESS: f32 = 2.0;
64/// Default midpoint-displacement jitter, as a fraction of segment length.
65pub(super) const DEFAULT_JITTER: f32 = 0.18;
66/// Default peak alpha of the full-area flash.
67pub(super) const DEFAULT_FLASH_INTENSITY: f32 = 0.35;
68/// Default strike duration in milliseconds.
69pub(super) const DEFAULT_DURATION_MS: f32 = 420.0;
70/// Default ambient interval in seconds (`0.0` disables the scheduler).
71pub(super) const DEFAULT_AMBIENT_INTERVAL_SECS: f32 = 0.0;
72
73/// Immutable knobs needed to build one strike's geometry and timing.
74#[derive(Clone, Copy)]
75struct StrikeConfig {
76   bolt_count: usize,
77   branch_depth: u32,
78   jitter: f32,
79   duration: f32,
80}
81
82/// Per-frame state of the [`Lightning`] effect, kept in egui's temporary data storage.
83#[derive(Clone)]
84struct LightningState {
85   /// Drives the ambient schedule (the strike geometry has its own seed).
86   rng: ParticleRng,
87   /// Strikes currently animating.
88   strikes: Vec<ActiveStrike>,
89   /// Monotonic counter mixed into each strike's seed.
90   strike_index: u64,
91   /// Ambient cursor in `ctx` time; `None` until the scheduler first runs.
92   next_strike_at: Option<f64>,
93}
94
95impl LightningState {
96   fn new(seed: u64) -> Self {
97      Self {
98         rng: ParticleRng::new(seed),
99         strikes: Vec::new(),
100         strike_index: 0,
101         next_strike_at: None,
102      }
103   }
104}
105
106/// One bolt strike: its geometry, age and flicker schedule.
107#[derive(Clone, Debug)]
108struct ActiveStrike {
109   /// Every polyline of the bolt (trunk first, then branches), normalized.
110   polylines: Vec<Vec<Pos2>>,
111   /// Seconds since the strike was queued.
112   age: f32,
113   /// Total duration in seconds.
114   duration: f32,
115   /// Flicker pulses making up the flash envelope.
116   sub_flashes: Vec<SubFlash>,
117}
118
119impl ActiveStrike {
120   /// Builds a strike from `config`, drawing all randomness from `rng`.
121   fn new(rng: &mut ParticleRng, config: StrikeConfig) -> Self {
122      let mut polylines = Vec::new();
123      for _ in 0..config.bolt_count {
124         let from = Pos2::new(rng.range(BOLT_X_MIN, BOLT_X_MAX), 0.0);
125         let to = Pos2::new(rng.range(BOLT_X_MIN, BOLT_X_MAX), 1.0);
126         polylines.extend(generate_bolt_with_branches(
127            rng,
128            from,
129            to,
130            BOLT_LEVELS,
131            config.jitter,
132            config.branch_depth,
133         ));
134      }
135
136      Self {
137         polylines,
138         age: 0.0,
139         duration: config.duration,
140         sub_flashes: SubFlash::pulses(rng),
141      }
142   }
143}
144
145/// One flicker pulse of a strike's flash.
146#[derive(Clone, Copy, Debug)]
147struct SubFlash {
148   /// Start of the pulse, as a fraction of the strike duration.
149   start: f32,
150   /// Peak brightness of the pulse in `0.0..=1.0`.
151   height: f32,
152}
153
154impl SubFlash {
155   /// The named pulses for one strike, with jittered heights.
156   fn pulses(rng: &mut ParticleRng) -> Vec<Self> {
157      SUB_FLASH_STARTS
158         .iter()
159         .zip(SUB_FLASH_HEIGHTS)
160         .map(|(&start, height)| Self {
161            start,
162            height: (height * rng.range(SUB_FLASH_JITTER_MIN, SUB_FLASH_JITTER_MAX))
163               .clamp(0.0, 1.0),
164         })
165         .collect()
166   }
167
168   /// Pulse value at `u = age / duration`: fast attack, exponential decay.
169   fn value(self, u: f32) -> f32 {
170      if u <= self.start {
171         0.0
172      } else if u < self.start + SUB_FLASH_ATTACK {
173         (u - self.start) / SUB_FLASH_ATTACK
174      } else {
175         (-(u - self.start - SUB_FLASH_ATTACK) / SUB_FLASH_DECAY).exp()
176      }
177   }
178}
179
180/// A configurable lightning effect for egui.
181///
182/// `Lightning` paints a jagged bolt plus a full-area flash — the "room lighting
183/// up" — on an egui layer. It maintains its state between frames using egui's
184/// temporary data storage and never disturbs the host layout.
185///
186/// A strike is episodic: it lives for [`duration_ms`](Self::duration_ms) and
187/// flickers through several sub-flashes. Queue one immediately with
188/// [`strike`](Self::strike) (the tuned host-triggered path) or let the ambient
189/// scheduler fire on its own through [`ambient_interval_secs`](Self::ambient_interval_secs).
190#[derive(Debug)]
191pub struct Lightning {
192   id: Id,
193   color: Color32,
194   bolt_count: usize,
195   branch_depth: u32,
196   thickness: f32,
197   jitter: f32,
198   flash_intensity: f32,
199   duration: f32,
200   ambient_interval: f32,
201   layer_order: Order,
202   custom_layer: Option<LayerId>,
203   custom_area: Option<Rect>,
204}
205
206impl Lightning {
207   /// Creates a new lightning effect with a unique ID.
208   pub fn new(id_source: impl std::hash::Hash + std::fmt::Debug) -> Self {
209      Self {
210         id: Id::new(id_source),
211         color: DEFAULT_COLOR,
212         bolt_count: DEFAULT_BOLT_COUNT,
213         branch_depth: DEFAULT_BRANCH_DEPTH,
214         thickness: DEFAULT_THICKNESS,
215         jitter: DEFAULT_JITTER,
216         flash_intensity: DEFAULT_FLASH_INTENSITY,
217         duration: DEFAULT_DURATION_MS / 1000.0,
218         ambient_interval: DEFAULT_AMBIENT_INTERVAL_SECS,
219         layer_order: Order::Foreground,
220         custom_layer: None,
221         custom_area: None,
222      }
223   }
224
225   /// Builds a lightning effect from [`LightningSettings`].
226   ///
227   /// Use the same `id_source` for [`show`](Self::show) and
228   /// [`strike`](Self::strike) so both address one state.
229   pub fn from_settings(
230      id_source: impl std::hash::Hash + std::fmt::Debug,
231      settings: &LightningSettings,
232   ) -> Self {
233      let lightning = Self::new(id_source)
234         .color(settings.color)
235         .bolt_count(settings.bolt_count)
236         .branch_depth(settings.branch_depth)
237         .thickness(settings.thickness)
238         .jitter(settings.jitter)
239         .flash_intensity(settings.flash_intensity)
240         .duration_ms(settings.duration_ms)
241         .ambient_interval_secs(settings.ambient_interval_secs);
242
243      match settings.layer {
244         ParticleLayer::Background => lightning.on_background(),
245         ParticleLayer::Foreground => lightning.on_foreground(),
246         ParticleLayer::Top => lightning.on_top(),
247      }
248   }
249
250   /// Sets the bolt and flash color.
251   pub fn color(mut self, color: Color32) -> Self {
252      self.color = color;
253      self
254   }
255
256   /// Sets how many bolts a strike draws.
257   pub fn bolt_count(mut self, bolt_count: usize) -> Self {
258      self.bolt_count = bolt_count;
259      self
260   }
261
262   /// Sets the branch recursion depth (`0` draws the trunk only).
263   pub fn branch_depth(mut self, branch_depth: u32) -> Self {
264      self.branch_depth = branch_depth;
265      self
266   }
267
268   /// Sets the bright core stroke thickness in pixels.
269   pub fn thickness(mut self, thickness: f32) -> Self {
270      self.thickness = thickness.max(0.0);
271      self
272   }
273
274   /// Sets the midpoint-displacement jitter as a fraction of segment length.
275   pub fn jitter(mut self, jitter: f32) -> Self {
276      self.jitter = jitter.max(0.0);
277      self
278   }
279
280   /// Sets the peak alpha of the full-area flash in `0.0..=1.0`.
281   pub fn flash_intensity(mut self, intensity: f32) -> Self {
282      self.flash_intensity = intensity.clamp(0.0, 1.0);
283      self
284   }
285
286   /// Sets the strike duration in milliseconds.
287   pub fn duration_ms(mut self, duration_ms: f32) -> Self {
288      self.duration = (duration_ms / 1000.0).max(0.0);
289      self
290   }
291
292   /// Sets the ambient interval in seconds (`0.0` disables the scheduler).
293   pub fn ambient_interval_secs(mut self, secs: f32) -> Self {
294      self.ambient_interval = secs.max(0.0);
295      self
296   }
297
298   /// Places the lightning on the Debug layer (on top of everything).
299   pub fn on_top(mut self) -> Self {
300      self.layer_order = Order::Debug;
301      self
302   }
303
304   /// Places the lightning on the Foreground layer.
305   pub fn on_foreground(mut self) -> Self {
306      self.layer_order = Order::Foreground;
307      self
308   }
309
310   /// Places the lightning on the Background layer.
311   pub fn on_background(mut self) -> Self {
312      self.layer_order = Order::Background;
313      self
314   }
315
316   /// Provides full control over the rendering layer.
317   ///
318   /// This overrides `on_top`, `on_foreground`, and `on_background`.
319   pub fn layer(mut self, layer_id: LayerId) -> Self {
320      self.custom_layer = Some(layer_id);
321      self
322   }
323
324   /// Restricts the lightning effect to a specific area.
325   ///
326   /// If not set, it defaults to `ctx.content_rect()`.
327   pub fn area(mut self, area: Rect) -> Self {
328      self.custom_area = Some(area);
329      self
330   }
331
332   /// Queues a strike immediately and requests a repaint.
333   ///
334   /// This is the host-triggered path; `Lightning::from_settings(id, &settings).strike(ctx)`
335   /// is the tuned one. `strike` uses the same `id_source` as [`show`](Self::show),
336   /// so both must be constructed with the same id for the queued strike to be
337   /// painted. A degenerate area is a no-op.
338   pub fn strike(self, ctx: &Context) {
339      if resolve_target(
340         ctx,
341         self.id,
342         self.layer_order,
343         self.custom_layer,
344         self.custom_area,
345      )
346      .is_none()
347      {
348         return;
349      }
350
351      let id = self.id;
352      let config = self.strike_config();
353      ctx.data_mut(|data| {
354         let state = data.get_temp_mut_or_insert_with(id, || LightningState::new(id.value()));
355         queue_strike(
356            &mut state.strikes,
357            &mut state.strike_index,
358            id,
359            config,
360         );
361      });
362      ctx.request_repaint();
363   }
364
365   /// Updates and renders the lightning effect.
366   ///
367   /// Call this every frame. While a strike animates it requests a repaint;
368   /// when idle it requests nothing (ambient off) or wakes exactly for the next
369   /// ambient strike, so an idle effect costs the host no frames.
370   pub fn show(self, ctx: &Context) {
371      let Some((layer_id, area)) = resolve_target(
372         ctx,
373         self.id,
374         self.layer_order,
375         self.custom_layer,
376         self.custom_area,
377      ) else {
378         return;
379      };
380
381      let dt = ctx.input(|i| i.stable_dt).min(0.1);
382      let now = ctx.input(|i| i.time);
383      let id = self.id;
384      let config = self.strike_config();
385
386      let (mut strikes, mut rng, mut strike_index, mut next_strike_at) = ctx.data_mut(|data| {
387         let state = data.get_temp_mut_or_insert_with(id, || LightningState::new(id.value()));
388         (
389            std::mem::take(&mut state.strikes),
390            state.rng,
391            state.strike_index,
392            state.next_strike_at,
393         )
394      });
395
396      // Advance and retire the active strikes.
397      for strike in &mut strikes {
398         strike.age += dt;
399      }
400      strikes.retain(|strike| strike.age < strike.duration);
401
402      // Ambient scheduler: wake exactly when the next strike is due.
403      let mut ambient_delay = None;
404      if self.ambient_interval > 0.0 {
405         let interval = f64::from(self.ambient_interval);
406         if next_strike_at.is_none() {
407            next_strike_at = Some(now + jittered_interval(&mut rng, interval));
408         }
409         if next_strike_at.is_some_and(|at| now >= at) {
410            queue_strike(&mut strikes, &mut strike_index, id, config);
411            next_strike_at = Some(now + jittered_interval(&mut rng, interval));
412         }
413         if let Some(at) = next_strike_at {
414            ambient_delay = Some(Duration::from_secs_f64((at - now).max(0.0)));
415         }
416      }
417
418      // The flash goes down first so the bolt reads as being lit by it.
419      let painter = ctx.layer_painter(layer_id);
420      for strike in &strikes {
421         let envelope = flash_envelope(strike.age, strike.duration, &strike.sub_flashes);
422         if envelope <= 0.0 {
423            continue;
424         }
425
426         let flash_alpha = (self.flash_intensity * envelope).clamp(0.0, 1.0);
427         painter.add(Shape::rect_filled(
428            area,
429            0.0,
430            self.color.gamma_multiply(flash_alpha),
431         ));
432
433         let halo_color = self.color.gamma_multiply((HALO_ALPHA * envelope).clamp(0.0, 1.0));
434         let mid_color = self.color.gamma_multiply((MID_ALPHA * envelope).clamp(0.0, 1.0));
435         let core_color = self.color.gamma_multiply(envelope.clamp(0.0, 1.0));
436
437         for polyline in &strike.polylines {
438            let points: Vec<Pos2> = polyline.iter().map(|p| to_pixels(*p, area)).collect();
439            painter.add(Shape::line(
440               points.clone(),
441               Stroke::new(self.thickness * HALO_WIDTH, halo_color),
442            ));
443            painter.add(Shape::line(
444               points.clone(),
445               Stroke::new(self.thickness * MID_WIDTH, mid_color),
446            ));
447            painter.add(Shape::line(
448               points,
449               Stroke::new(self.thickness, core_color),
450            ));
451         }
452      }
453
454      let has_active = !strikes.is_empty();
455
456      ctx.data_mut(|data| {
457         let state = data.get_temp_mut_or_insert_with(id, || LightningState::new(id.value()));
458         state.strikes = strikes;
459         state.rng = rng;
460         state.strike_index = strike_index;
461         state.next_strike_at = next_strike_at;
462      });
463
464      // Repaint policy: animate while a strike is live, wake exactly for the
465      // next ambient strike, and request nothing at all when idle.
466      if has_active {
467         ctx.request_repaint();
468      } else if let Some(delay) = ambient_delay {
469         ctx.request_repaint_after(delay);
470      }
471   }
472
473   /// The immutable per-strike knobs, with the branch depth bounded.
474   fn strike_config(&self) -> StrikeConfig {
475      StrikeConfig {
476         bolt_count: self.bolt_count,
477         branch_depth: self.branch_depth.min(MAX_BRANCH_DEPTH),
478         jitter: self.jitter,
479         duration: self.duration,
480      }
481   }
482}
483
484/// Queues one strike, seeding its geometry from `id ^ strike_index`.
485fn queue_strike(
486   strikes: &mut Vec<ActiveStrike>,
487   strike_index: &mut u64,
488   id: Id,
489   config: StrikeConfig,
490) {
491   let seed = id.value() ^ *strike_index;
492   *strike_index = strike_index.wrapping_add(1);
493   let mut rng = ParticleRng::new(seed);
494   strikes.push(ActiveStrike::new(&mut rng, config));
495}
496
497/// The next ambient interval, jittered by ±[`AMBIENT_JITTER`].
498fn jittered_interval(rng: &mut ParticleRng, interval: f64) -> f64 {
499   let spread = 1.0 + f64::from(rng.range(-AMBIENT_JITTER, AMBIENT_JITTER));
500   (interval * spread).max(0.0)
501}
502
503/// The flash envelope over `age` for a strike of `duration`.
504///
505/// Sums the strike's sub-flashes and clamps to `0.0..=1.0`; exactly `0` at both
506/// ends so nothing pops in or out.
507fn flash_envelope(age: f32, duration: f32, sub_flashes: &[SubFlash]) -> f32 {
508   if duration <= 0.0 {
509      return 0.0;
510   }
511
512   let u = (age / duration).clamp(0.0, 1.0);
513   if u <= 0.0 || u >= 1.0 {
514      return 0.0;
515   }
516
517   let sum: f32 = sub_flashes.iter().map(|pulse| pulse.height * pulse.value(u)).sum();
518   sum.clamp(0.0, 1.0)
519}
520
521/// Maps a normalized point into `area` pixels.
522fn to_pixels(point: Pos2, area: Rect) -> Pos2 {
523   Pos2::new(
524      area.min.x + point.x * area.width(),
525      area.min.y + point.y * area.height(),
526   )
527}
528
529/// Recursive midpoint displacement from `from` to `to`.
530///
531/// Each level splits every segment and pushes its midpoint perpendicular to the
532/// segment by `jitter * segment_length`; the displacement therefore halves each
533/// level because the segment length does. Points are clamped into `0.0..=1.0`
534/// on both axes so no bolt or branch can leave the effect area.
535fn generate_bolt(
536   rng: &mut ParticleRng,
537   from: Pos2,
538   to: Pos2,
539   levels: u32,
540   jitter: f32,
541) -> Vec<Pos2> {
542   let mut points = vec![from, to];
543
544   for _ in 0..levels {
545      let mut next = Vec::with_capacity(points.len() * 2 - 1);
546      for pair in points.windows(2) {
547         let a = pair[0];
548         let b = pair[1];
549         next.push(a);
550
551         let delta = b - a;
552         let length = delta.length();
553         let midpoint = a + delta * 0.5;
554         if length > 0.0 {
555            let perpendicular = vec2(-delta.y, delta.x) / length;
556            let amplitude = jitter * length;
557            next.push(midpoint + perpendicular * rng.range(-amplitude, amplitude));
558         } else {
559            next.push(midpoint);
560         }
561      }
562      next.push(*points.last().expect("a bolt has at least two points"));
563      points = next;
564   }
565
566   for point in &mut points {
567      point.x = point.x.clamp(0.0, 1.0);
568      point.y = point.y.clamp(0.0, 1.0);
569   }
570
571   points
572}
573
574/// The trunk plus every branch, all as normalized polylines.
575fn generate_bolt_with_branches(
576   rng: &mut ParticleRng,
577   from: Pos2,
578   to: Pos2,
579   levels: u32,
580   jitter: f32,
581   branch_depth: u32,
582) -> Vec<Vec<Pos2>> {
583   let trunk = generate_bolt(rng, from, to, levels, jitter);
584   let mut polylines = vec![trunk.clone()];
585   spawn_branches(
586      rng,
587      &trunk,
588      levels,
589      jitter,
590      branch_depth,
591      &mut polylines,
592   );
593   polylines
594}
595
596/// Recursively forks a branch off `parent`, bounded by `remaining`.
597fn spawn_branches(
598   rng: &mut ParticleRng,
599   parent: &[Pos2],
600   levels: u32,
601   jitter: f32,
602   remaining: u32,
603   out: &mut Vec<Vec<Pos2>>,
604) {
605   if remaining == 0 || parent.len() < 2 {
606      return;
607   }
608
609   let branch = spawn_branch(rng, parent, levels, jitter);
610   out.push(branch.clone());
611   spawn_branches(
612      rng,
613      &branch,
614      levels.saturating_sub(1),
615      jitter,
616      remaining - 1,
617      out,
618   );
619}
620
621/// One branch: shorter than its parent, angled away and jittered.
622fn spawn_branch(rng: &mut ParticleRng, parent: &[Pos2], levels: u32, jitter: f32) -> Vec<Pos2> {
623   let from = parent[0];
624   let to = parent[parent.len() - 1];
625   let length = (to - from).length();
626   let direction = if length > 0.0 {
627      (to - from) / length
628   } else {
629      vec2(0.0, 1.0)
630   };
631
632   // Attach somewhere along the lower part of the parent.
633   let lower = (parent.len() as f32 * BRANCH_START_MIN) as usize;
634   let upper = (parent.len() as f32 * BRANCH_START_MAX) as usize;
635   let index = (rng.range(lower as f32, (upper + 1) as f32) as usize).min(parent.len() - 1);
636   let start = parent[index];
637
638   let sign = if rng.f32() < 0.5 { -1.0 } else { 1.0 };
639   let angle = sign * rng.range(BRANCH_ANGLE_MIN, BRANCH_ANGLE_MAX);
640   let branch_length = length * rng.range(BRANCH_LENGTH_MIN, BRANCH_LENGTH_MAX);
641   let end = start + rotate(direction, angle) * branch_length;
642
643   generate_bolt(
644      rng,
645      start,
646      end,
647      levels.saturating_sub(1).max(1),
648      jitter,
649   )
650}
651
652/// Rotates `v` by `angle` radians.
653fn rotate(v: Vec2, angle: f32) -> Vec2 {
654   let (sin, cos) = angle.sin_cos();
655   vec2(v.x * cos - v.y * sin, v.x * sin + v.y * cos)
656}
657
658#[cfg(test)]
659mod tests {
660   use super::*;
661   use egui::__run_test_ctx;
662   use egui::{ViewportId, vec2};
663
664   /// The named pulses without the per-strike height jitter.
665   fn sample_sub_flashes() -> Vec<SubFlash> {
666      SUB_FLASH_STARTS
667         .iter()
668         .zip(SUB_FLASH_HEIGHTS)
669         .map(|(&start, height)| SubFlash { start, height })
670         .collect()
671   }
672
673   /// Perpendicular distance from `point` to the line through `from` and `to`.
674   fn distance_to_line(point: Pos2, from: Pos2, to: Pos2) -> f32 {
675      let delta = to - from;
676      let length = delta.length();
677      if length <= 0.0 {
678         return (point - from).length();
679      }
680      ((point.x - from.x) * delta.y - (point.y - from.y) * delta.x).abs() / length
681   }
682
683   #[test]
684   fn same_seed_generates_identical_bolts() {
685      let from = Pos2::new(0.3, 0.0);
686      let to = Pos2::new(0.7, 1.0);
687      let mut a = ParticleRng::new(42);
688      let mut b = ParticleRng::new(42);
689      assert_eq!(
690         generate_bolt(&mut a, from, to, BOLT_LEVELS, DEFAULT_JITTER),
691         generate_bolt(&mut b, from, to, BOLT_LEVELS, DEFAULT_JITTER)
692      );
693   }
694
695   #[test]
696   fn different_seeds_generate_different_bolts() {
697      let from = Pos2::new(0.3, 0.0);
698      let to = Pos2::new(0.7, 1.0);
699      let mut a = ParticleRng::new(1);
700      let mut b = ParticleRng::new(2);
701      assert_ne!(
702         generate_bolt(&mut a, from, to, BOLT_LEVELS, DEFAULT_JITTER),
703         generate_bolt(&mut b, from, to, BOLT_LEVELS, DEFAULT_JITTER)
704      );
705   }
706
707   #[test]
708   fn every_bolt_point_stays_normalized() {
709      let mut rng = ParticleRng::new(7);
710      let polylines = generate_bolt_with_branches(
711         &mut rng,
712         Pos2::new(0.5, 0.0),
713         Pos2::new(0.5, 1.0),
714         BOLT_LEVELS,
715         0.3,
716         3,
717      );
718
719      for polyline in &polylines {
720         for point in polyline {
721            assert!(
722               (0.0..=1.0).contains(&point.x),
723               "x {} left the normalized area",
724               point.x
725            );
726            assert!(
727               (0.0..=1.0).contains(&point.y),
728               "y {} left the normalized area",
729               point.y
730            );
731         }
732      }
733   }
734
735   #[test]
736   fn branch_count_is_bounded_by_depth() {
737      let mut rng = ParticleRng::new(3);
738      for depth in 0..=4u32 {
739         let polylines = generate_bolt_with_branches(
740            &mut rng,
741            Pos2::new(0.5, 0.0),
742            Pos2::new(0.5, 1.0),
743            BOLT_LEVELS,
744            DEFAULT_JITTER,
745            depth,
746         );
747         assert!(
748            polylines.len() <= 1 + depth as usize,
749            "depth {depth} produced {} polylines",
750            polylines.len()
751         );
752      }
753   }
754
755   #[test]
756   fn bolt_is_jagged_not_a_straight_line() {
757      let from = Pos2::new(0.5, 0.0);
758      let to = Pos2::new(0.5, 1.0);
759      let mut rng = ParticleRng::new(11);
760      let bolt = generate_bolt(&mut rng, from, to, BOLT_LEVELS, DEFAULT_JITTER);
761
762      let max_deviation = bolt
763         .iter()
764         .map(|point| distance_to_line(*point, from, to))
765         .fold(0.0_f32, f32::max);
766      assert!(
767         max_deviation > 0.01,
768         "the bolt deviated only {max_deviation} from the straight line"
769      );
770   }
771
772   #[test]
773   fn strike_with_degenerate_area_is_a_noop() {
774      __run_test_ctx(|ctx| {
775         let area = Rect::from_min_size(Pos2::ZERO, vec2(0.0, 64.0));
776         Lightning::new("test_lightning_degenerate").area(area).strike(ctx);
777         assert!(
778            ctx.data(|d| d.get_temp::<LightningState>(Id::new("test_lightning_degenerate")))
779               .is_none(),
780            "a degenerate area must not create state"
781         );
782      });
783   }
784
785   #[test]
786   fn strike_queues_a_bolt() {
787      __run_test_ctx(|ctx| {
788         let area = Rect::from_min_size(Pos2::ZERO, vec2(64.0, 64.0));
789         Lightning::new("test_lightning_queue").area(area).strike(ctx);
790
791         let state = ctx
792            .data(|d| d.get_temp::<LightningState>(Id::new("test_lightning_queue")))
793            .expect("LightningState should exist after strike");
794         assert_eq!(state.strikes.len(), 1);
795         assert!(!state.strikes[0].polylines.is_empty());
796      });
797   }
798
799   #[test]
800   fn envelope_is_zero_at_both_ends() {
801      let pulses = sample_sub_flashes();
802      assert_eq!(
803         flash_envelope(0.0, 1.0, &pulses),
804         0.0,
805         "the envelope must start at zero"
806      );
807      assert_eq!(
808         flash_envelope(1.0, 1.0, &pulses),
809         0.0,
810         "the envelope must end at zero"
811      );
812      assert_eq!(
813         flash_envelope(1.0, 0.0, &pulses),
814         0.0,
815         "a zero-length strike must have a zero envelope"
816      );
817   }
818
819   #[test]
820   fn envelope_stays_within_bounds() {
821      let pulses = sample_sub_flashes();
822      for step in 0..=1000 {
823         let age = step as f32 / 1000.0;
824         let value = flash_envelope(age, 1.0, &pulses);
825         assert!(
826            (0.0..=1.0).contains(&value),
827            "envelope {value} left 0.0..=1.0 at age {age}"
828         );
829      }
830   }
831
832   #[test]
833   fn envelope_has_a_sub_flash_after_its_first_decay() {
834      let pulses = sample_sub_flashes();
835      let samples = 1000;
836      let values: Vec<f32> = (0..=samples)
837         .map(|i| flash_envelope(i as f32 / samples as f32, 1.0, &pulses))
838         .collect();
839
840      let first_max = (1..samples)
841         .find(|&i| values[i] > values[i - 1] && values[i] > values[i + 1])
842         .expect("the envelope must have an interior local maximum");
843
844      let mut decayed = false;
845      let mut later_max = false;
846      for i in (first_max + 1)..samples {
847         if values[i] < values[i - 1] {
848            decayed = true;
849         }
850         if decayed && values[i] > values[i - 1] && values[i] > values[i + 1] {
851            later_max = true;
852            break;
853         }
854      }
855
856      assert!(
857         later_max,
858         "the envelope has no sub-flash after its first decay"
859      );
860   }
861
862   #[test]
863   fn strike_ends_after_its_duration() {
864      __run_test_ctx(|ctx| {
865         let area = Rect::from_min_size(Pos2::ZERO, vec2(64.0, 64.0));
866         let id = Id::new("test_lightning_ends");
867         Lightning::new("test_lightning_ends").duration_ms(200.0).area(area).strike(ctx);
868
869         assert_eq!(
870            ctx.data(|d| d.get_temp::<LightningState>(id))
871               .expect("state after strike")
872               .strikes
873               .len(),
874            1
875         );
876
877         for frame in 0..8 {
878            ctx.input_mut(|i| {
879               i.stable_dt = 0.05;
880               i.time = f64::from(frame) * 0.05;
881            });
882            Lightning::new("test_lightning_ends").duration_ms(200.0).area(area).show(ctx);
883         }
884
885         let state = ctx
886            .data(|d| d.get_temp::<LightningState>(id))
887            .expect("LightningState should exist after show");
888         assert!(
889            state.strikes.is_empty(),
890            "a strike outlived its duration: {} still active",
891            state.strikes.len()
892         );
893      });
894   }
895
896   #[test]
897   fn idle_show_requests_no_repaint() {
898      let ctx = Context::default();
899      let area = Rect::from_min_size(Pos2::ZERO, vec2(64.0, 64.0));
900      let raw_input = egui::RawInput {
901         screen_rect: Some(area),
902         ..Default::default()
903      };
904
905      // Two warm-up passes: the first creates the state and sends egui's
906      // first-frame `SetTheme` viewport command, whose repaint request would
907      // otherwise land on the pass under test.
908      for _ in 0..2 {
909         ctx.run_ui(raw_input.clone(), |ui| {
910            Lightning::new("test_lightning_idle").area(area).show(ui.ctx());
911         })
912         .drop_without_applying_deltas();
913      }
914
915      let output = ctx.run_ui(raw_input, |ui| {
916         Lightning::new("test_lightning_idle").area(area).show(ui.ctx());
917      });
918      let delay = output.viewport_output[&ViewportId::ROOT].repaint_delay;
919      assert_eq!(
920         delay,
921         Duration::MAX,
922         "an idle lightning effect requested a repaint after {delay:?}"
923      );
924   }
925
926   #[test]
927   fn from_settings_maps_fields_and_layer() {
928      let settings = LightningSettings {
929         color: Color32::from_rgb(1, 2, 3),
930         bolt_count: 4,
931         branch_depth: 5,
932         thickness: 3.5,
933         jitter: 0.4,
934         flash_intensity: 0.6,
935         duration_ms: 800.0,
936         ambient_interval_secs: 3.0,
937         layer: ParticleLayer::Top,
938         ..Default::default()
939      };
940
941      let lightning = Lightning::from_settings("lightning_map", &settings);
942      assert_eq!(lightning.color, Color32::from_rgb(1, 2, 3));
943      assert_eq!(lightning.bolt_count, 4);
944      assert_eq!(lightning.branch_depth, 5);
945      assert_eq!(lightning.thickness, 3.5);
946      assert_eq!(lightning.jitter, 0.4);
947      assert_eq!(lightning.flash_intensity, 0.6);
948      assert_eq!(lightning.duration, 0.8);
949      assert_eq!(lightning.ambient_interval, 3.0);
950      assert_eq!(lightning.layer_order, Order::Debug);
951
952      let background = Lightning::from_settings(
953         "lightning_map_bg",
954         &LightningSettings {
955            layer: ParticleLayer::Background,
956            ..Default::default()
957         },
958      );
959      assert_eq!(background.layer_order, Order::Background);
960   }
961
962   #[test]
963   fn new_matches_default_settings() {
964      let from_new = Lightning::new("lightning_defaults");
965      let from_settings = Lightning::from_settings(
966         "lightning_defaults",
967         &LightningSettings::default(),
968      );
969      assert_eq!(from_new.color, from_settings.color);
970      assert_eq!(from_new.bolt_count, from_settings.bolt_count);
971      assert_eq!(from_new.branch_depth, from_settings.branch_depth);
972      assert_eq!(from_new.thickness, from_settings.thickness);
973      assert_eq!(from_new.jitter, from_settings.jitter);
974      assert_eq!(
975         from_new.flash_intensity,
976         from_settings.flash_intensity
977      );
978      assert_eq!(from_new.duration, from_settings.duration);
979      assert_eq!(
980         from_new.ambient_interval,
981         from_settings.ambient_interval
982      );
983      assert_eq!(from_new.layer_order, from_settings.layer_order);
984   }
985}