facett-core 0.1.8

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
//! **The "cyber-toon" decoration kernel** — domain-agnostic, pure-CPU L0 beauty
//! helpers that turn a flat instance graph into a luminous, animated dashboard
//! **with zero GPU / vello dependency**, so they light up identically native AND
//! in the wasm demo. The graph/map skins (and the population showcase) compose
//! these over the [`prim`](super::prim) instances they already lower.
//!
//! Everything here is:
//! - **pure** — a function of its inputs (no globals, no `Instant::now()`); all
//!   animation is driven by an injected `phase`/`t` so snapshots are stable (the
//!   determinism LAW). A test feeds a fixed clock and the picture is reproducible.
//! - **L0-only** — emits [`QuadInstance`]/[`LineInstance`]/[`CircleInstance`]/
//!   [`RingInstance`], the same vocabulary the CPU raster + GPU pipeline both draw,
//!   so the effects work on the always-available CPU lane (`l1-vello` is an
//!   *elevation*, never a requirement).
//! - **domain-agnostic** — it knows nothing about "Person" or "Debt"; a caller
//!   decides which nodes glow, which edges flow, where a shockwave fires.
//!
//! ## The helper menu (maps onto the aesthetic spec)
//! - [`glow_halo`] — a soft colored light-bleed ring behind a node (the glossy
//!   orb look). The CPU bloom substitute for an L1 gaussian glow.
//! - [`ao_shadow`] — a toon drop-shadow / ambient-occlusion disc under a node
//!   (deeper where nodes bunch — clustered nodes pool a darker shadow).
//! - [`dash_line`] — an **animated dash-array** stroke (a `→` flow of segments that
//!   marches along the edge as `phase` advances) — debt flowing toward enforcement.
//! - [`particles_on_edge`] — light-tracer **particles** zipping along an edge
//!   (money moving Loan→Person), positions a pure function of `phase`.
//! - [`pulse`] / [`strobe`] — deterministic 0..1 envelopes for the Debt **pulse**
//!   and PoliceRecord **strobe** (animation timing, injected-clock driven).
//! - [`shockwave_ring`] — a radial ring expanding from a clicked node.
//! - [`bloom_composite`] — a cheap separable box-blur **bloom** post-pass over a
//!   straight-RGBA8 [`Frame`]: bright pixels bleed light into their neighbours so
//!   intersecting neon lines mix into colored "explosions".
//! - [`lerp_rgba`] / [`gradient_by_scalar`] — a 2-stop (or multi-stop) colour ramp
//!   keyed by a scalar in `[0,1]` (the gradient-fill-by-scalar engine primitive).

use super::Frame;
use super::prim::{CircleInstance, LineInstance, QuadInstance, RingInstance, shape};

/// τ (one full animation cycle) — phases are in **cycles** (`0.0..1.0` is one loop),
/// so a caller advances `phase += dt / period` and the look is period-independent.
const TAU: f32 = std::f32::consts::TAU;

// ───────────────────────────────────────────────────────────────────────────
// Colour ramps (gradient-by-scalar engine primitive)
// ───────────────────────────────────────────────────────────────────────────

/// Linear-interpolate two straight RGBA colours at `t ∈ [0,1]`.
#[inline]
pub fn lerp_rgba(a: [f32; 4], b: [f32; 4], t: f32) -> [f32; 4] {
    let t = t.clamp(0.0, 1.0);
    [
        a[0] + (b[0] - a[0]) * t,
        a[1] + (b[1] - a[1]) * t,
        a[2] + (b[2] - a[2]) * t,
        a[3] + (b[3] - a[3]) * t,
    ]
}

/// Sample a piecewise-linear **multi-stop** gradient (`stops` sorted by position)
/// at `t ∈ [0,1]` — the domain-agnostic "fill-by-scalar" ramp (debt-heat, degree,
/// population). Clamps below the first / above the last stop. `stops` must be
/// non-empty.
pub fn gradient_by_scalar(stops: &[(f32, [f32; 4])], t: f32) -> [f32; 4] {
    debug_assert!(!stops.is_empty(), "gradient needs at least one stop");
    let t = t.clamp(0.0, 1.0);
    if t <= stops[0].0 {
        return stops[0].1;
    }
    for w in stops.windows(2) {
        let (p0, c0) = w[0];
        let (p1, c1) = w[1];
        if t <= p1 {
            let span = (p1 - p0).max(1e-6);
            return lerp_rgba(c0, c1, (t - p0) / span);
        }
    }
    stops[stops.len() - 1].1
}

// ───────────────────────────────────────────────────────────────────────────
// Animation envelopes (injected-clock driven, pure)
// ───────────────────────────────────────────────────────────────────────────

/// A smooth `0..1` **pulse** envelope (a raised cosine) at `phase` cycles —
/// `0 → 1 → 0` once per cycle. The Debt/KfmCase "intense glow" breathing.
#[inline]
pub fn pulse(phase: f32) -> f32 {
    0.5 - 0.5 * (phase * TAU).cos()
}

/// A hard **strobe** envelope at `phase` cycles with duty `duty ∈ (0,1)` — `1.0`
/// for the first `duty` of each cycle, else `0.0` (PoliceRecord hazard flash). A
/// pure step so snapshots at a fixed `phase` are exact.
#[inline]
pub fn strobe(phase: f32, duty: f32) -> f32 {
    if phase.rem_euclid(1.0) < duty.clamp(0.0, 1.0) { 1.0 } else { 0.0 }
}

// ───────────────────────────────────────────────────────────────────────────
// Node beauty: glow halo + ambient-occlusion drop-shadow
// ───────────────────────────────────────────────────────────────────────────

/// A soft colored **glow halo** ring behind a node marker — the CPU light-bleed
/// that makes a node read as a glossy orb. `r` is the marker radius, `intensity`
/// the halo alpha (`0..1`), `spread` the outer-radius multiplier (≥ 1).
#[inline]
pub fn glow_halo(center: [f32; 2], r: f32, color: [f32; 3], intensity: f32, spread: f32) -> RingInstance {
    let spread = spread.max(1.05);
    RingInstance {
        center,
        radius: r * spread,
        inner: r * 1.05,
        color: [color[0], color[1], color[2], intensity.clamp(0.0, 1.0)],
        aa: r * 0.5 + 1.5, // a wide AA band = a soft, blurry bleed (no GPU blur needed)
    }
}

/// A toon **ambient-occlusion drop-shadow** disc under a node — a dark, soft circle
/// offset down-right, so clustered nodes pool a deeper shadow (the "ray-traced"
/// grounding). `r` the marker radius, `depth` the shadow alpha (`0..1`).
#[inline]
pub fn ao_shadow(center: [f32; 2], r: f32, depth: f32) -> CircleInstance {
    CircleInstance {
        center: [center[0] + r * 0.22, center[1] + r * 0.30],
        radius: r * 1.18,
        color: [0.0, 0.0, 0.0, depth.clamp(0.0, 1.0)],
        aa: r * 0.6 + 1.5,
    }
}

/// Build glow + AO for a batch of node markers in one pass (the common case): pull
/// the SQUARE/CIRCLE/DIAMOND node quads out of `quads`, and for each emit an AO
/// shadow (under) + a glow halo (also under, over the shadow). Returns
/// `(ao_quads, glow_quads)` already lowered, so a caller pushes
/// `shadows → glows → original quads` for the layered orb look. `intensity` scales
/// the glow alpha; `ao` the shadow alpha.
pub fn node_decor(
    quads: &[QuadInstance],
    intensity: f32,
    ao: f32,
) -> (Vec<QuadInstance>, Vec<QuadInstance>) {
    let mut shadows = Vec::with_capacity(quads.len());
    let mut glows = Vec::with_capacity(quads.len());
    for q in quads {
        if q.shape == shape::SQUARE || q.shape == shape::CIRCLE || q.shape == shape::DIAMOND {
            let r = q.radius;
            shadows.push(ao_shadow(q.center, r, ao).lower());
            glows.push(glow_halo(q.center, r, [q.color[0], q.color[1], q.color[2]], intensity, 2.1).lower());
        }
    }
    (shadows, glows)
}

// ───────────────────────────────────────────────────────────────────────────
// Edge beauty: animated dash-array + particle tracers
// ───────────────────────────────────────────────────────────────────────────

/// An **animated dash-array** line `a → b`: the segment is chopped into `dash`-long
/// lit pieces separated by `gap`, and the whole pattern **marches** along the
/// direction by `phase` (in dash+gap units) — a directional "flow". Pure in
/// `phase`, so a fixed clock gives a fixed picture. `half_width`/`aa`/`color` as
/// for a [`LineInstance`].
pub fn dash_line(
    a: [f32; 2],
    b: [f32; 2],
    half_width: f32,
    aa: f32,
    color: [f32; 4],
    dash: f32,
    gap: f32,
    phase: f32,
) -> Vec<LineInstance> {
    let dash = dash.max(0.5);
    let gap = gap.max(0.0);
    let period = dash + gap;
    let dx = b[0] - a[0];
    let dy = b[1] - a[1];
    let len = (dx * dx + dy * dy).sqrt();
    if len < 1e-3 {
        return Vec::new();
    }
    let (ux, uy) = (dx / len, dy / len);
    // March offset (always backward against the flow so dashes appear to move a→b).
    let mut t = -(phase.rem_euclid(1.0) * period);
    let mut out = Vec::new();
    while t < len {
        let s = t.max(0.0);
        let e = (t + dash).min(len);
        if e > s {
            out.push(LineInstance::round(
                [a[0] + ux * s, a[1] + uy * s],
                [a[0] + ux * e, a[1] + uy * e],
                half_width,
                aa,
                color,
            ));
        }
        t += period;
    }
    out
}

/// **Light-tracer particles** zipping along an edge `a → b`: `n` evenly-spaced
/// discs whose fractional position is `(i/n + phase) mod 1`, so they flow a→b as
/// `phase` advances. Pure in `phase`. `radius`/`color` per particle.
pub fn particles_on_edge(
    a: [f32; 2],
    b: [f32; 2],
    n: usize,
    radius: f32,
    color: [f32; 4],
    phase: f32,
) -> Vec<CircleInstance> {
    if n == 0 {
        return Vec::new();
    }
    (0..n)
        .map(|i| {
            let f = ((i as f32 / n as f32) + phase).rem_euclid(1.0);
            // Brighten toward the head of the trail (a fading comet look).
            let head = 0.55 + 0.45 * f; // dim at the tail, bright at the front
            let c = [color[0], color[1], color[2], (color[3] * head).clamp(0.0, 1.0)];
            CircleInstance {
                center: [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f],
                radius: radius * (0.6 + 0.6 * f),
                color: c,
                aa: 1.0,
            }
        })
        .collect()
}

// ───────────────────────────────────────────────────────────────────────────
// Interaction: shockwave
// ───────────────────────────────────────────────────────────────────────────

/// A radial **shockwave** ring expanding from `center` as `t ∈ [0,1]` runs
/// (0 = just fired, 1 = faded out): the ring grows `r0 → r0 + reach` while its
/// alpha fades `1 → 0`. Pure in `t`. The neon-dashboard "click ripple".
#[inline]
pub fn shockwave_ring(center: [f32; 2], r0: f32, reach: f32, color: [f32; 3], t: f32) -> RingInstance {
    let t = t.clamp(0.0, 1.0);
    let radius = r0 + reach * t;
    let thickness = (reach * 0.10).max(2.0);
    RingInstance {
        center,
        radius,
        inner: (radius - thickness).max(0.0),
        color: [color[0], color[1], color[2], (1.0 - t).powf(1.4)],
        aa: 2.0,
    }
}

// ───────────────────────────────────────────────────────────────────────────
// Bloom (CPU post-process)
// ───────────────────────────────────────────────────────────────────────────

/// A cheap separable **bloom** post-pass over a straight-RGBA8 [`Frame`]: extract
/// the pixels brighter than `threshold` (`0..1` luma), blur them with a small
/// box-blur of `radius` px (separable, two passes), and **add** that light back
/// onto the frame scaled by `intensity`. Intersecting neon lines therefore bleed
/// and mix into colored "explosions" — the spec's bloom, with no GPU.
///
/// Pure, in-place, deterministic. `radius` is clamped to a small range so a wasm
/// frame stays snappy. A no-op when `intensity <= 0` or `radius == 0`.
pub fn bloom_composite(frame: &mut Frame, threshold: f32, radius: u32, intensity: f32) {
    if intensity <= 0.0 || radius == 0 || frame.width == 0 || frame.height == 0 {
        return;
    }
    let (w, h) = (frame.width as usize, frame.height as usize);
    let radius = radius.min(8) as i32; // keep the blur cheap (wasm-safe)
    let n = w * h;

    // 1) Bright-pass: keep only pixels whose luma exceeds the threshold.
    let mut bright = vec![0f32; n * 3];
    for (i, px) in frame.rgba.chunks_exact(4).enumerate() {
        let r = px[0] as f32 / 255.0;
        let g = px[1] as f32 / 255.0;
        let b = px[2] as f32 / 255.0;
        let luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
        if luma > threshold {
            // soft knee so the bloom ramps in rather than hard-clips
            let k = ((luma - threshold) / (1.0 - threshold).max(1e-3)).clamp(0.0, 1.0);
            bright[i * 3] = r * k;
            bright[i * 3 + 1] = g * k;
            bright[i * 3 + 2] = b * k;
        }
    }

    // 2) Separable box-blur (horizontal then vertical) of the bright layer.
    let win = (2 * radius + 1) as f32;
    let mut tmp = vec![0f32; n * 3];
    // horizontal
    for y in 0..h {
        for x in 0..w {
            let mut acc = [0f32; 3];
            for d in -radius..=radius {
                let sx = (x as i32 + d).clamp(0, w as i32 - 1) as usize;
                let si = (y * w + sx) * 3;
                acc[0] += bright[si];
                acc[1] += bright[si + 1];
                acc[2] += bright[si + 2];
            }
            let di = (y * w + x) * 3;
            tmp[di] = acc[0] / win;
            tmp[di + 1] = acc[1] / win;
            tmp[di + 2] = acc[2] / win;
        }
    }
    // vertical (back into `bright`)
    for y in 0..h {
        for x in 0..w {
            let mut acc = [0f32; 3];
            for d in -radius..=radius {
                let sy = (y as i32 + d).clamp(0, h as i32 - 1) as usize;
                let si = (sy * w + x) * 3;
                acc[0] += tmp[si];
                acc[1] += tmp[si + 1];
                acc[2] += tmp[si + 2];
            }
            let di = (y * w + x) * 3;
            bright[di] = acc[0] / win;
            bright[di + 1] = acc[1] / win;
            bright[di + 2] = acc[2] / win;
        }
    }

    // 3) Additive composite the blurred light back onto the frame.
    for (i, px) in frame.rgba.chunks_exact_mut(4).enumerate() {
        for k in 0..3 {
            let base = px[k] as f32 / 255.0;
            let add = bright[i * 3 + k] * intensity;
            px[k] = ((base + add) * 255.0).round().clamp(0.0, 255.0) as u8;
        }
        // bloom never makes a transparent pixel opaque on its own, but it lifts
        // alpha where it added real light so the glow is visible over the ground.
        if px[3] == 0 {
            let lit = bright[i * 3] + bright[i * 3 + 1] + bright[i * 3 + 2];
            if lit * intensity > 0.02 {
                px[3] = (lit * intensity * 255.0).round().clamp(0.0, 255.0) as u8;
            }
        }
    }
}

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

    /// INJECT-ASSERT: the 2-stop / multi-stop ramp lands on the right colours at
    /// the ends and blends in the middle (the gradient-by-scalar primitive).
    #[test]
    fn gradient_by_scalar_ramps_and_clamps() {
        let stops = [(0.0, [0.0, 0.0, 1.0, 1.0]), (1.0, [1.0, 0.0, 0.0, 1.0])];
        assert_eq!(gradient_by_scalar(&stops, -1.0), stops[0].1, "clamps below");
        assert_eq!(gradient_by_scalar(&stops, 2.0), stops[1].1, "clamps above");
        let mid = gradient_by_scalar(&stops, 0.5);
        assert!((mid[0] - 0.5).abs() < 1e-5 && (mid[2] - 0.5).abs() < 1e-5, "mid blends: {mid:?}");
        // A three-stop ramp picks the right segment.
        let three = [(0.0, [0.0; 4]), (0.5, [1.0, 1.0, 1.0, 1.0]), (1.0, [0.0; 4])];
        assert!(gradient_by_scalar(&three, 0.25)[0] > 0.4, "rises to the mid stop");
        assert!(gradient_by_scalar(&three, 0.75)[0] < 0.6, "falls past the mid stop");
    }

    /// INJECT-ASSERT: pulse/strobe are deterministic envelopes — the SAME phase
    /// gives the SAME value (snapshot-stable), and they actually move.
    #[test]
    fn pulse_and_strobe_are_deterministic_envelopes() {
        assert!(pulse(0.0).abs() < 1e-5, "pulse troughs at phase 0");
        assert!((pulse(0.5) - 1.0).abs() < 1e-5, "pulse peaks at half-cycle");
        assert_eq!(pulse(0.25), pulse(0.25), "deterministic");
        assert_eq!(strobe(0.05, 0.5), 1.0, "strobe lit in the duty window");
        assert_eq!(strobe(0.75, 0.5), 0.0, "strobe dark past the duty window");
        assert_eq!(strobe(1.05, 0.5), 1.0, "strobe is periodic (phase wraps)");
    }

    /// INJECT-ASSERT: the animated dash marches — at a different phase the lit
    /// segments sit at different offsets (a real flow, not a static dash).
    #[test]
    fn dash_line_marches_with_phase() {
        let a = [0.0, 0.0];
        let b = [100.0, 0.0];
        let d0 = dash_line(a, b, 2.0, 1.0, [1.0; 4], 8.0, 6.0, 0.0);
        let d1 = dash_line(a, b, 2.0, 1.0, [1.0; 4], 8.0, 6.0, 0.5);
        assert!(!d0.is_empty() && !d1.is_empty(), "the dash produced segments");
        // The pattern marches: the first lit segment ENDS at a different offset
        // (its start may clamp to 0 at both phases, but the lit length shifts).
        assert_ne!(d0[0].b[0], d1[0].b[0], "dashes moved along the edge with phase");
        // Determinism: same phase, same geometry.
        let d0b = dash_line(a, b, 2.0, 1.0, [1.0; 4], 8.0, 6.0, 0.0);
        assert_eq!(d0[0].a, d0b[0].a, "same phase → same dash (start)");
        assert_eq!(d0[0].b, d0b[0].b, "same phase → same dash (end)");
    }

    /// INJECT-ASSERT: particles ride the edge — at phase 0 they sit at i/n, and a
    /// phase bump slides every particle along the segment by the same fraction.
    #[test]
    fn particles_flow_along_the_edge() {
        let a = [0.0, 0.0];
        let b = [200.0, 0.0];
        let p0 = particles_on_edge(a, b, 4, 3.0, [1.0; 4], 0.0);
        let p1 = particles_on_edge(a, b, 4, 3.0, [1.0; 4], 0.25);
        assert_eq!(p0.len(), 4);
        // The first particle starts at the tail and advances a quarter-edge.
        assert!((p0[0].center[0] - 0.0).abs() < 1e-3, "particle 0 at the tail at phase 0");
        assert!((p1[0].center[0] - 50.0).abs() < 1e-3, "particle 0 advanced to x=50 at phase 0.25");
    }

    /// INJECT-ASSERT: the shockwave grows + fades with t (a real ripple), staying
    /// a valid annulus.
    #[test]
    fn shockwave_expands_and_fades() {
        let early = shockwave_ring([50.0, 50.0], 10.0, 100.0, [1.0, 0.4, 0.9], 0.1);
        let late = shockwave_ring([50.0, 50.0], 10.0, 100.0, [1.0, 0.4, 0.9], 0.9);
        assert!(late.radius > early.radius, "the ring expands with t");
        assert!(late.color[3] < early.color[3], "and fades out");
        assert!(early.inner < early.radius, "valid annulus");
    }

    /// INJECT-ASSERT: node_decor emits a shadow + a glow under each node marker
    /// (one of each per drawable quad), AND the glow carries the node's colour.
    #[test]
    fn node_decor_emits_shadow_and_glow_per_node() {
        let q = QuadInstance {
            center: [40.0, 40.0],
            radius: 8.0,
            inner: 2.0,
            color: [0.2, 0.8, 1.0, 1.0],
            aa: 1.0,
            shape: shape::SQUARE,
            _pad: [0.0, 0.0],
        };
        let (shadows, glows) = node_decor(&[q], 0.5, 0.4);
        assert_eq!(shadows.len(), 1, "one AO shadow under the node");
        assert_eq!(glows.len(), 1, "one glow halo behind the node");
        // The glow is the node's hue at the requested intensity; the shadow is dark.
        assert!((glows[0].color[2] - 1.0).abs() < 1e-5, "glow carries the node blue");
        assert!((glows[0].color[3] - 0.5).abs() < 1e-5, "glow at the requested intensity");
        assert!(shadows[0].color[0] < 0.05, "shadow is black");
        assert!(glows[0].radius > q.radius, "glow is larger than the marker (a bleed)");
    }

    /// INJECT-ASSERT: bloom actually spreads light — a single bright pixel on a
    /// dark frame lights up its neighbours after the pass (and the source stays
    /// lit), proving the box-blur bleed ran (not "didn't panic").
    #[test]
    fn bloom_spreads_light_to_neighbours() {
        let (w, h) = (9u32, 9u32);
        let mut rgba = vec![0u8; (w * h * 4) as usize];
        // One opaque white pixel dead centre on a transparent black ground.
        let c = ((4 * w + 4) * 4) as usize;
        rgba[c..c + 4].copy_from_slice(&[255, 255, 255, 255]);
        let mut frame = Frame { width: w, height: h, rgba };

        let neighbour = ((4 * w + 5) * 4) as usize; // one px to the right
        assert_eq!(frame.rgba[neighbour], 0, "neighbour starts dark");
        bloom_composite(&mut frame, 0.5, 2, 1.0);
        assert!(frame.rgba[neighbour] > 0, "bloom bled light onto the neighbour");
        assert!(frame.rgba[neighbour + 3] > 0, "and lifted its alpha so the glow shows");
        // The original bright pixel stays lit.
        assert!(frame.rgba[c] > 200, "the source pixel stays bright");
    }

    /// INJECT-ASSERT: bloom is a no-op when disabled (intensity 0 / radius 0) — the
    /// frame is byte-identical, so toggling bloom off truly removes it.
    #[test]
    fn bloom_disabled_is_a_noop() {
        let (w, h) = (5u32, 5u32);
        let mut rgba = vec![0u8; (w * h * 4) as usize];
        rgba[((2 * w + 2) * 4) as usize..((2 * w + 2) * 4) as usize + 4]
            .copy_from_slice(&[255, 255, 255, 255]);
        let before = rgba.clone();
        let mut frame = Frame { width: w, height: h, rgba };
        bloom_composite(&mut frame, 0.5, 0, 1.0);
        assert_eq!(frame.rgba, before, "radius 0 → no-op");
        bloom_composite(&mut frame, 0.5, 2, 0.0);
        assert_eq!(frame.rgba, before, "intensity 0 → no-op");
    }
}