facett-core 0.1.0

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
//! **effects** — motion + bloom for the facett look. Pure egui, **glow backend
//! only**: everything here paints with layered alpha shapes on an
//! [`egui::Painter`], so it works without a custom GPU shader.
//!
//! Three layers:
//! 1. [`easing`] — `t∈[0,1] → [0,1]` tweens (cubic, back, elastic, bounce).
//! 2. **bloom helpers** — [`glow_rect`], [`glow_text`], [`shimmer`], and a tiny
//!    [`ParticleBurst`].
//! 3. [`RavenSprite`] — the signature effect: a raven flies in along a bezier
//!    arc with ease-out and *perches* on a target [`egui::Rect`], then idle-bobs.
//!
//! # Where the GPU path lands later
//! True bloom (a bright-pass + separable Gaussian on a HDR target) and the raven
//! as a textured/instanced sprite belong in the **wgpu kernel** (see
//! `.nornir/wgpu-plan.md`): they'd replace the layered-alpha [`glow_rect`] /
//! shape-painted raven here behind the same call sites. Until then this CPU path
//! is the reference and is fully headless-testable.

use egui::{Color32, Painter, Pos2, Rect, Stroke, Vec2, pos2, vec2};

/// `t∈[0,1] → [0,1]` tweening curves. Each is clamped at the ends so
/// `f(0.0) == 0.0` and `f(1.0) == 1.0` exactly (the demo + tests rely on it).
pub mod easing {
    /// Identity ramp.
    pub fn linear(t: f32) -> f32 {
        t.clamp(0.0, 1.0)
    }

    /// Smooth accelerate-then-decelerate (the workhorse).
    pub fn ease_in_out_cubic(t: f32) -> f32 {
        let t = t.clamp(0.0, 1.0);
        if t < 0.5 {
            4.0 * t * t * t
        } else {
            let f = -2.0 * t + 2.0;
            1.0 - f * f * f / 2.0
        }
    }

    /// Overshoots slightly past 1.0 near the end, then settles — a confident
    /// "snap into place". Endpoints are still exactly 0 and 1.
    pub fn ease_out_back(t: f32) -> f32 {
        let t = t.clamp(0.0, 1.0);
        const C1: f32 = 1.70158;
        const C3: f32 = C1 + 1.0;
        let f = t - 1.0;
        1.0 + C3 * f * f * f + C1 * f * f
    }

    /// Springy elastic settle (decaying sine). `f(0)=0`, `f(1)=1`.
    pub fn elastic(t: f32) -> f32 {
        let t = t.clamp(0.0, 1.0);
        if t == 0.0 || t == 1.0 {
            return t;
        }
        const C4: f32 = std::f32::consts::TAU / 3.0;
        2.0_f32.powf(-10.0 * t) * ((t * 10.0 - 0.75) * C4).sin() + 1.0
    }

    /// Gravity bounce-to-rest. `f(0)=0`, `f(1)=1`.
    pub fn bounce(t: f32) -> f32 {
        let t = t.clamp(0.0, 1.0);
        const N1: f32 = 7.5625;
        const D1: f32 = 2.75;
        if t < 1.0 / D1 {
            N1 * t * t
        } else if t < 2.0 / D1 {
            let t = t - 1.5 / D1;
            N1 * t * t + 0.75
        } else if t < 2.5 / D1 {
            let t = t - 2.25 / D1;
            N1 * t * t + 0.9375
        } else {
            let t = t - 2.625 / D1;
            N1 * t * t + 0.984375
        }
    }
}

/// Linear interpolation between two colours in straight (unmultiplied) space.
fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 {
    let t = t.clamp(0.0, 1.0);
    let l = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t).round() as u8;
    Color32::from_rgba_unmultiplied(l(a.r(), b.r()), l(a.g(), b.g()), l(a.b(), b.b()), l(a.a(), b.a()))
}

/// Paint a soft **bloom** around `rect`: `layers` concentric rounded strokes that
/// fade out as they expand. `intensity∈[0,1]` scales the alpha (e.g. animate it
/// with a [`easing`] curve for a pulse). Cheap stand-in for true HDR bloom.
pub fn glow_rect(painter: &Painter, rect: Rect, color: Color32, intensity: f32, layers: u32) {
    let intensity = intensity.clamp(0.0, 1.0);
    let layers = layers.max(1);
    for i in 0..layers {
        let f = i as f32 / layers as f32; // 0 (inner) .. ~1 (outer)
        let grow = 1.0 + f * 7.0;
        let alpha = ((1.0 - f) * (1.0 - f) * 90.0 * intensity) as u8;
        if alpha == 0 {
            continue;
        }
        let c = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), alpha);
        painter.rect_stroke(
            rect.expand(grow),
            4.0 + grow,
            Stroke::new(1.5 + f * 2.0, c),
            egui::StrokeKind::Outside,
        );
    }
}

/// Draw `text` at `pos` with a coloured **glow halo** behind it (offset copies
/// in a ring), then the crisp text on top. `glow` is usually `theme.glow`.
#[allow(clippy::too_many_arguments)]
pub fn glow_text(
    painter: &Painter,
    pos: Pos2,
    anchor: egui::Align2,
    text: &str,
    font: egui::FontId,
    text_color: Color32,
    glow: Color32,
    intensity: f32,
) {
    let intensity = intensity.clamp(0.0, 1.0);
    let halo = Color32::from_rgba_unmultiplied(glow.r(), glow.g(), glow.b(), (70.0 * intensity) as u8);
    for r in [3.0_f32, 2.0, 1.0] {
        for k in 0..8 {
            let a = std::f32::consts::TAU * k as f32 / 8.0;
            let off = vec2(a.cos(), a.sin()) * r;
            painter.text(pos + off, anchor, text, font.clone(), halo);
        }
    }
    painter.text(pos, anchor, text, font, text_color);
}

/// Paint an animated **shimmer** sweep across `rect`: a diagonal highlight band
/// at phase `t∈[0,1]` (wrap `t` yourself, e.g. `(time * speed).fract()`). Built
/// from a handful of vertical alpha bars, brightest at the band centre.
pub fn shimmer(painter: &Painter, rect: Rect, color: Color32, t: f32) {
    let bars = 24;
    let band = 0.18; // half-width of the highlight, in [0,1] of the sweep
    let center = t.rem_euclid(1.0) * (1.0 + 2.0 * band) - band;
    for i in 0..bars {
        let x = (i as f32 + 0.5) / bars as f32; // 0..1 across the rect
        let d = (x - center).abs() / band;
        if d >= 1.0 {
            continue;
        }
        let g = (1.0 - d) * (1.0 - d); // brightness, 0 at edges → 1 at band centre
        // Brighten toward white at the band centre for a glinting highlight,
        // then carry that as a translucent overlay.
        let bright = lerp_color(color, Color32::WHITE, g * 0.6);
        let c = Color32::from_rgba_unmultiplied(bright.r(), bright.g(), bright.b(), (g * 130.0) as u8);
        let bx0 = rect.left() + x * rect.width();
        let bw = rect.width() / bars as f32 + 1.0;
        // a gentle diagonal: shear the bar top by the band offset
        let shear = (x - 0.5) * rect.height() * 0.25;
        let seg = Rect::from_min_max(pos2(bx0, rect.top() + shear), pos2(bx0 + bw, rect.bottom() + shear))
            .intersect(rect);
        painter.rect_filled(seg, 0.0, c);
    }
}

/// One particle: position, velocity, age. Internal to [`ParticleBurst`].
#[derive(Clone, Copy)]
struct Particle {
    pos: Pos2,
    vel: Vec2,
    /// Seconds lived.
    age: f32,
}

/// A tiny **particle burst** — circles fired outward from a point that fall under
/// gravity and fade out. Deterministic given the same `seed`, so a test can pin
/// it. Advance with [`ParticleBurst::update`], render with
/// [`ParticleBurst::paint`].
#[derive(Clone)]
pub struct ParticleBurst {
    particles: Vec<Particle>,
    color: Color32,
    /// px/s² downward.
    gravity: f32,
    /// Total lifetime of a particle, seconds.
    lifetime: f32,
    elapsed: f32,
}

impl ParticleBurst {
    /// Fire `count` particles from `origin` outward in a ring, with speeds
    /// jittered by `seed` (deterministic — no RNG crate).
    pub fn new(origin: Pos2, count: usize, color: Color32, seed: u64) -> Self {
        let mut h = seed ^ 0x9E37_79B9_7F4A_7C15;
        let mut rng = || {
            // SplitMix64 — deterministic, dependency-free.
            h = h.wrapping_add(0x9E37_79B9_7F4A_7C15);
            let mut z = h;
            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
            ((z ^ (z >> 31)) as f64 / u64::MAX as f64) as f32
        };
        let particles = (0..count)
            .map(|i| {
                let a = std::f32::consts::TAU * i as f32 / count.max(1) as f32;
                let speed = 90.0 + rng() * 140.0;
                let up = 0.6 + rng() * 0.4; // bias upward so they arc nicely
                Particle { pos: origin, vel: vec2(a.cos() * speed, a.sin() * speed - 120.0 * up), age: 0.0 }
            })
            .collect();
        Self { particles, color, gravity: 520.0, lifetime: 1.1, elapsed: 0.0 }
    }

    /// Advance every particle by `dt` seconds (integrate velocity + gravity).
    pub fn update(&mut self, dt: f32) {
        let dt = dt.max(0.0);
        self.elapsed += dt;
        for p in &mut self.particles {
            p.vel.y += self.gravity * dt;
            p.pos += p.vel * dt;
            p.age += dt;
        }
    }

    /// All particles have aged past their lifetime — the burst is done and can
    /// be dropped.
    pub fn finished(&self) -> bool {
        self.elapsed >= self.lifetime
    }

    /// Paint each live particle as a fading circle.
    pub fn paint(&self, painter: &Painter) {
        for p in &self.particles {
            let life = (1.0 - p.age / self.lifetime).clamp(0.0, 1.0);
            if life <= 0.0 {
                continue;
            }
            let a = (life * 220.0) as u8;
            let c = Color32::from_rgba_unmultiplied(self.color.r(), self.color.g(), self.color.b(), a);
            painter.circle_filled(p.pos, 1.0 + life * 2.5, c);
        }
    }
}

/// Quadratic bezier point at `t∈[0,1]` for control points `p0,p1,p2`.
fn bezier2(p0: Pos2, p1: Pos2, p2: Pos2, t: f32) -> Pos2 {
    let u = 1.0 - t;
    let x = u * u * p0.x + 2.0 * u * t * p1.x + t * t * p2.x;
    let y = u * u * p0.y + 2.0 * u * t * p1.y + t * t * p2.y;
    pos2(x, y)
}

/// The flight duration (seconds) of a [`RavenSprite`] from launch to perch.
pub const RAVEN_FLIGHT_SECS: f32 = 1.4;

/// **The signature effect.** A stylized raven 🐦 (painted from shapes — no asset
/// dependency, so it never goes missing) that **flies in along a bezier arc with
/// ease-out and perches** on a target [`egui::Rect`], then idle-bobs.
///
/// Motion is **deterministic given `(start, target, elapsed)`** — that's what
/// makes [`RavenSprite::pos_at`] unit-testable. The egui-driven loop is:
///
/// ```ignore
/// raven.update(ctx);       // reads ctx.input(i.time), requests a repaint while flying
/// raven.paint(&painter);   // draws the bird at its current pos
/// ```
#[derive(Clone)]
pub struct RavenSprite {
    start: Pos2,
    target: Pos2,
    /// `None` until the first [`update`](Self::update) pins the launch time.
    launch_time: Option<f64>,
    /// Filled by [`update`](Self::update) each frame.
    current: Pos2,
    /// 0 while flying, 1 once perched.
    perched: bool,
    /// Seconds since launch (drives the idle bob once perched).
    elapsed: f32,
    color: Color32,
    /// Face left (-1) or right (+1) — set from the flight direction.
    facing: f32,
    scale: f32,
}

impl Default for RavenSprite {
    fn default() -> Self {
        Self::new()
    }
}

impl RavenSprite {
    /// A raven waiting off the top-left, ready to be aimed with [`fly_to`].
    pub fn new() -> Self {
        Self {
            start: pos2(-40.0, -40.0),
            target: pos2(0.0, 0.0),
            launch_time: None,
            current: pos2(-40.0, -40.0),
            perched: false,
            elapsed: 0.0,
            color: Color32::from_rgb(18, 18, 22),
            facing: 1.0,
            scale: 1.0,
        }
    }

    /// Launch point of the flight (defaults to off-screen top-left).
    pub fn from(mut self, start: Pos2) -> Self {
        self.start = start;
        self.current = start;
        self
    }

    /// Raven body colour (override the default near-black, e.g. for `hugin_noir`
    /// you might tint the beak/eye via `accent` separately).
    pub fn color(mut self, color: Color32) -> Self {
        self.color = color;
        self
    }

    /// Overall sprite scale (1.0 ≈ a ~14px-tall bird body).
    pub fn scale(mut self, scale: f32) -> Self {
        self.scale = scale.max(0.1);
        self
    }

    /// **Aim the raven** at `target`: it will perch centred on the top edge of
    /// the rect (like landing on a table row). Resets the flight clock.
    pub fn fly_to(mut self, target: Rect) -> Self {
        // Perch on the top edge, centred — feet just above the row.
        self.target = pos2(target.center().x, target.top());
        self.facing = if self.target.x >= self.start.x { 1.0 } else { -1.0 };
        self.launch_time = None;
        self.perched = false;
        self
    }

    /// The flight path control point — a high arc above the midpoint, so the
    /// raven swoops down onto the perch. Pure function of `start`/`target`.
    fn control(&self) -> Pos2 {
        let mid = pos2((self.start.x + self.target.x) * 0.5, (self.start.y + self.target.y) * 0.5);
        let span = (self.target - self.start).length().max(1.0);
        pos2(mid.x, mid.y - span * 0.45) // lift the arc
    }

    /// **Deterministic position** at `elapsed` seconds since launch — the testable
    /// core. Eases along the bezier arc with [`easing::ease_out_back`] (a
    /// confident landing), holds the perch afterwards. Adds a subtle idle bob
    /// once perched.
    pub fn pos_at(&self, elapsed: f32) -> Pos2 {
        if elapsed >= RAVEN_FLIGHT_SECS {
            // Perched: tiny vertical bob (±1.5px, ~0.6Hz).
            let bob = ((elapsed - RAVEN_FLIGHT_SECS) * std::f32::consts::TAU * 0.6).sin() * 1.5;
            return pos2(self.target.x, self.target.y + bob);
        }
        let lin = (elapsed / RAVEN_FLIGHT_SECS).clamp(0.0, 1.0);
        let t = easing::ease_out_back(lin);
        bezier2(self.start, self.control(), self.target, t)
    }

    /// True once the flight is over (raven is perched, idle-bobbing).
    pub fn is_perched(&self) -> bool {
        self.perched
    }

    /// Current sprite position (set by the last [`update`](Self::update)).
    pub fn pos(&self) -> Pos2 {
        self.current
    }

    /// Drive the animation from the egui clock. Pins the launch time on first
    /// call, advances `current`, and `request_repaint`s while still in flight so
    /// the animation keeps ticking. Call once per frame before [`paint`].
    pub fn update(&mut self, ctx: &egui::Context) {
        let now = ctx.input(|i| i.time);
        let launch = *self.launch_time.get_or_insert(now);
        let elapsed = (now - launch) as f32;
        self.advance(elapsed);
        if !self.perched {
            ctx.request_repaint();
        }
    }

    /// Time-driven core shared by [`update`] and the headless test: set the
    /// current pos + facing + perched flag for `elapsed` seconds since launch.
    pub fn advance(&mut self, elapsed: f32) {
        self.elapsed = elapsed;
        self.current = self.pos_at(elapsed);
        self.perched = elapsed >= RAVEN_FLIGHT_SECS;
    }

    /// Paint the raven at its current position. A stylized corvid built from a
    /// body ellipse-ish blob, swept wings, a wedge tail, a head, and a beak —
    /// wings flap while flying and fold once perched.
    pub fn paint(&self, painter: &Painter) {
        let c = self.current;
        let s = self.scale;
        let f = self.facing;
        let body = self.color;
        let stroke = Stroke::new(1.0 * s, body);

        // Wing phase: flapping in flight, near-folded when perched.
        let flap = if self.perched {
            0.15
        } else {
            // 0..1..0 flap, ~5Hz
            (self.elapsed * std::f32::consts::TAU * 5.0).sin() * 0.5 + 0.5
        };
        let wing_lift = (flap - 0.5) * 9.0 * s; // up/down sweep of the wingtip

        // Body: a rounded blob (overlapping circles read as a corvid body).
        painter.circle_filled(c, 5.0 * s, body);
        painter.circle_filled(c + vec2(-3.5 * s * f, 1.0 * s), 3.5 * s, body);

        // Tail: a wedge trailing behind (opposite the facing direction).
        let tail_root = c + vec2(-5.0 * s * f, 0.5 * s);
        painter.add(egui::Shape::convex_polygon(
            vec![
                tail_root,
                tail_root + vec2(-7.0 * s * f, -2.5 * s),
                tail_root + vec2(-7.5 * s * f, 1.0 * s),
                tail_root + vec2(-6.0 * s * f, 3.0 * s),
            ],
            body,
            stroke,
        ));

        // Wings: two swept triangles from the back, lifting with the flap.
        let shoulder = c + vec2(-s * f, -1.5 * s);
        let tip_far = shoulder + vec2(-9.0 * s * f, -wing_lift - 2.0 * s);
        let tip_near = shoulder + vec2(-4.0 * s * f, -wing_lift * 0.5 + 4.0 * s);
        painter.add(egui::Shape::convex_polygon(
            vec![shoulder, tip_far, tip_near],
            body,
            stroke,
        ));

        // Head + beak, leading the body in the facing direction.
        let head = c + vec2(4.5 * s * f, -2.5 * s);
        painter.circle_filled(head, 3.0 * s, body);
        let beak_color = Color32::from_rgb(40, 30, 18); // charcoal beak
        painter.add(egui::Shape::convex_polygon(
            vec![
                head + vec2(2.5 * s * f, -0.5 * s),
                head + vec2(6.5 * s * f, 0.5 * s),
                head + vec2(2.5 * s * f, 1.5 * s),
            ],
            beak_color,
            Stroke::NONE,
        ));
        // Eye: a tiny bright glint so it reads as alive.
        painter.circle_filled(head + vec2(1.2 * s * f, -0.8 * s), 0.8 * s, Color32::from_rgb(230, 220, 210));
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A named easing fn (keeps the test table readable / clippy-clean).
    type NamedEasing = (&'static str, fn(f32) -> f32);

    fn approx(a: f32, b: f32, eps: f32) -> bool {
        (a - b).abs() <= eps
    }

    #[test]
    fn easing_fns_hit_their_endpoints() {
        let fns: [NamedEasing; 5] = [
            ("linear", easing::linear),
            ("cubic", easing::ease_in_out_cubic),
            ("back", easing::ease_out_back),
            ("elastic", easing::elastic),
            ("bounce", easing::bounce),
        ];
        for (name, f) in fns {
            assert!(approx(f(0.0), 0.0, 1e-5), "{name}(0) should be 0, got {}", f(0.0));
            assert!(approx(f(1.0), 1.0, 1e-5), "{name}(1) should be 1, got {}", f(1.0));
            // clamped outside [0,1]
            assert!(approx(f(-1.0), 0.0, 1e-5), "{name}(-1) clamps to 0");
            assert!(approx(f(2.0), 1.0, 1e-5), "{name}(2) clamps to 1");
        }
    }

    #[test]
    fn ease_out_back_overshoots_before_settling() {
        // The "back" curve should exceed 1.0 somewhere near the end.
        let peak = (60..100).map(|i| easing::ease_out_back(i as f32 / 100.0)).fold(0.0_f32, f32::max);
        assert!(peak > 1.0, "ease_out_back should overshoot, peak={peak}");
    }

    #[test]
    fn raven_starts_at_launch_and_converges_onto_target_rect() {
        let target = Rect::from_min_size(pos2(300.0, 200.0), vec2(180.0, 24.0));
        let mut raven = RavenSprite::new().from(pos2(-40.0, -40.0)).fly_to(target);

        // At t=0 it's at the launch point, not perched.
        raven.advance(0.0);
        assert!(!raven.is_perched());
        assert!(approx(raven.pos().x, -40.0, 0.5) && approx(raven.pos().y, -40.0, 0.5), "starts at launch");

        // Midway it's airborne and somewhere between start and target (and lifted
        // by the arc — above the straight line).
        raven.advance(RAVEN_FLIGHT_SECS * 0.5);
        assert!(!raven.is_perched());

        // After the full flight duration it has perched on the target's top edge,
        // centred, within a tiny bob amplitude.
        raven.advance(RAVEN_FLIGHT_SECS);
        assert!(raven.is_perched(), "perched after flight duration");
        let perch = pos2(target.center().x, target.top());
        let d = (raven.pos() - perch).length();
        assert!(d <= 2.0, "raven converges onto the perch (dist {d} px)");

        // And it stays on the perch (within the bob) for a while after.
        for k in 1..20 {
            raven.advance(RAVEN_FLIGHT_SECS + k as f32 * 0.05);
            assert!(approx(raven.pos().x, perch.x, 0.01), "x stays centred on perch");
            assert!(approx(raven.pos().y, perch.y, 2.0), "y stays within bob of perch");
        }
    }

    #[test]
    fn particle_burst_falls_and_finishes() {
        let mut b = ParticleBurst::new(pos2(100.0, 100.0), 16, Color32::WHITE, 42);
        assert!(!b.finished());
        for _ in 0..120 {
            b.update(1.0 / 60.0);
        }
        assert!(b.finished(), "burst should expire after its lifetime");
        // deterministic given the seed
        let mut a = ParticleBurst::new(pos2(0.0, 0.0), 8, Color32::WHITE, 7);
        let mut c = ParticleBurst::new(pos2(0.0, 0.0), 8, Color32::WHITE, 7);
        a.update(0.1);
        c.update(0.1);
        assert_eq!(a.particles[0].pos, c.particles[0].pos, "same seed → same motion");
    }
}