Skip to main content

facett_core/render/
decor.rs

1//! **The "cyber-toon" decoration kernel** — domain-agnostic, pure-CPU L0 beauty
2//! helpers that turn a flat instance graph into a luminous, animated dashboard
3//! **with zero GPU / vello dependency**, so they light up identically native AND
4//! in the wasm demo. The graph/map skins (and the population showcase) compose
5//! these over the [`prim`](super::prim) instances they already lower.
6//!
7//! Everything here is:
8//! - **pure** — a function of its inputs (no globals, no `Instant::now()`); all
9//!   animation is driven by an injected `phase`/`t` so snapshots are stable (the
10//!   determinism LAW). A test feeds a fixed clock and the picture is reproducible.
11//! - **L0-only** — emits [`QuadInstance`]/[`LineInstance`]/[`CircleInstance`]/
12//!   [`RingInstance`], the same vocabulary the CPU raster + GPU pipeline both draw,
13//!   so the effects work on the always-available CPU lane (`l1-vello` is an
14//!   *elevation*, never a requirement).
15//! - **domain-agnostic** — it knows nothing about "Person" or "Debt"; a caller
16//!   decides which nodes glow, which edges flow, where a shockwave fires.
17//!
18//! ## The helper menu (maps onto the aesthetic spec)
19//! - [`glow_halo`] — a soft colored light-bleed ring behind a node (the glossy
20//!   orb look). The CPU bloom substitute for an L1 gaussian glow.
21//! - [`ao_shadow`] — a toon drop-shadow / ambient-occlusion disc under a node
22//!   (deeper where nodes bunch — clustered nodes pool a darker shadow).
23//! - [`dash_line`] — an **animated dash-array** stroke (a `→` flow of segments that
24//!   marches along the edge as `phase` advances) — debt flowing toward enforcement.
25//! - [`particles_on_edge`] — light-tracer **particles** zipping along an edge
26//!   (money moving Loan→Person), positions a pure function of `phase`.
27//! - [`pulse`] / [`strobe`] — deterministic 0..1 envelopes for the Debt **pulse**
28//!   and PoliceRecord **strobe** (animation timing, injected-clock driven).
29//! - [`shockwave_ring`] — a radial ring expanding from a clicked node.
30//! - [`bloom_composite`] — a cheap separable box-blur **bloom** post-pass over a
31//!   straight-RGBA8 [`Frame`]: bright pixels bleed light into their neighbours so
32//!   intersecting neon lines mix into colored "explosions".
33//! - [`lerp_rgba`] / [`gradient_by_scalar`] — a 2-stop (or multi-stop) colour ramp
34//!   keyed by a scalar in `[0,1]` (the gradient-fill-by-scalar engine primitive).
35
36use super::Frame;
37use super::prim::{CircleInstance, LineInstance, QuadInstance, RingInstance, shape};
38
39/// τ (one full animation cycle) — phases are in **cycles** (`0.0..1.0` is one loop),
40/// so a caller advances `phase += dt / period` and the look is period-independent.
41const TAU: f32 = std::f32::consts::TAU;
42
43// ───────────────────────────────────────────────────────────────────────────
44// Colour ramps (gradient-by-scalar engine primitive)
45// ───────────────────────────────────────────────────────────────────────────
46
47/// Linear-interpolate two straight RGBA colours at `t ∈ [0,1]`.
48#[inline]
49pub fn lerp_rgba(a: [f32; 4], b: [f32; 4], t: f32) -> [f32; 4] {
50    let t = t.clamp(0.0, 1.0);
51    [
52        a[0] + (b[0] - a[0]) * t,
53        a[1] + (b[1] - a[1]) * t,
54        a[2] + (b[2] - a[2]) * t,
55        a[3] + (b[3] - a[3]) * t,
56    ]
57}
58
59/// Sample a piecewise-linear **multi-stop** gradient (`stops` sorted by position)
60/// at `t ∈ [0,1]` — the domain-agnostic "fill-by-scalar" ramp (debt-heat, degree,
61/// population). Clamps below the first / above the last stop. `stops` must be
62/// non-empty.
63pub fn gradient_by_scalar(stops: &[(f32, [f32; 4])], t: f32) -> [f32; 4] {
64    debug_assert!(!stops.is_empty(), "gradient needs at least one stop");
65    let t = t.clamp(0.0, 1.0);
66    if t <= stops[0].0 {
67        return stops[0].1;
68    }
69    for w in stops.windows(2) {
70        let (p0, c0) = w[0];
71        let (p1, c1) = w[1];
72        if t <= p1 {
73            let span = (p1 - p0).max(1e-6);
74            return lerp_rgba(c0, c1, (t - p0) / span);
75        }
76    }
77    stops[stops.len() - 1].1
78}
79
80// ───────────────────────────────────────────────────────────────────────────
81// Animation envelopes (injected-clock driven, pure)
82// ───────────────────────────────────────────────────────────────────────────
83
84/// A smooth `0..1` **pulse** envelope (a raised cosine) at `phase` cycles —
85/// `0 → 1 → 0` once per cycle. The Debt/KfmCase "intense glow" breathing.
86#[inline]
87pub fn pulse(phase: f32) -> f32 {
88    0.5 - 0.5 * (phase * TAU).cos()
89}
90
91/// A hard **strobe** envelope at `phase` cycles with duty `duty ∈ (0,1)` — `1.0`
92/// for the first `duty` of each cycle, else `0.0` (PoliceRecord hazard flash). A
93/// pure step so snapshots at a fixed `phase` are exact.
94#[inline]
95pub fn strobe(phase: f32, duty: f32) -> f32 {
96    if phase.rem_euclid(1.0) < duty.clamp(0.0, 1.0) { 1.0 } else { 0.0 }
97}
98
99// ───────────────────────────────────────────────────────────────────────────
100// Node beauty: glow halo + ambient-occlusion drop-shadow
101// ───────────────────────────────────────────────────────────────────────────
102
103/// A soft colored **glow halo** ring behind a node marker — the CPU light-bleed
104/// that makes a node read as a glossy orb. `r` is the marker radius, `intensity`
105/// the halo alpha (`0..1`), `spread` the outer-radius multiplier (≥ 1).
106#[inline]
107pub fn glow_halo(center: [f32; 2], r: f32, color: [f32; 3], intensity: f32, spread: f32) -> RingInstance {
108    let spread = spread.max(1.05);
109    RingInstance {
110        center,
111        radius: r * spread,
112        inner: r * 1.05,
113        color: [color[0], color[1], color[2], intensity.clamp(0.0, 1.0)],
114        aa: r * 0.5 + 1.5, // a wide AA band = a soft, blurry bleed (no GPU blur needed)
115    }
116}
117
118/// A toon **ambient-occlusion drop-shadow** disc under a node — a dark, soft circle
119/// offset down-right, so clustered nodes pool a deeper shadow (the "ray-traced"
120/// grounding). `r` the marker radius, `depth` the shadow alpha (`0..1`).
121#[inline]
122pub fn ao_shadow(center: [f32; 2], r: f32, depth: f32) -> CircleInstance {
123    CircleInstance {
124        center: [center[0] + r * 0.22, center[1] + r * 0.30],
125        radius: r * 1.18,
126        color: [0.0, 0.0, 0.0, depth.clamp(0.0, 1.0)],
127        aa: r * 0.6 + 1.5,
128    }
129}
130
131/// Build glow + AO for a batch of node markers in one pass (the common case): pull
132/// the SQUARE/CIRCLE/DIAMOND node quads out of `quads`, and for each emit an AO
133/// shadow (under) + a glow halo (also under, over the shadow). Returns
134/// `(ao_quads, glow_quads)` already lowered, so a caller pushes
135/// `shadows → glows → original quads` for the layered orb look. `intensity` scales
136/// the glow alpha; `ao` the shadow alpha.
137pub fn node_decor(
138    quads: &[QuadInstance],
139    intensity: f32,
140    ao: f32,
141) -> (Vec<QuadInstance>, Vec<QuadInstance>) {
142    let mut shadows = Vec::with_capacity(quads.len());
143    let mut glows = Vec::with_capacity(quads.len());
144    for q in quads {
145        if q.shape == shape::SQUARE || q.shape == shape::CIRCLE || q.shape == shape::DIAMOND {
146            let r = q.radius;
147            shadows.push(ao_shadow(q.center, r, ao).lower());
148            glows.push(glow_halo(q.center, r, [q.color[0], q.color[1], q.color[2]], intensity, 2.1).lower());
149        }
150    }
151    (shadows, glows)
152}
153
154// ───────────────────────────────────────────────────────────────────────────
155// Edge beauty: animated dash-array + particle tracers
156// ───────────────────────────────────────────────────────────────────────────
157
158/// An **animated dash-array** line `a → b`: the segment is chopped into `dash`-long
159/// lit pieces separated by `gap`, and the whole pattern **marches** along the
160/// direction by `phase` (in dash+gap units) — a directional "flow". Pure in
161/// `phase`, so a fixed clock gives a fixed picture. `half_width`/`aa`/`color` as
162/// for a [`LineInstance`].
163pub fn dash_line(
164    a: [f32; 2],
165    b: [f32; 2],
166    half_width: f32,
167    aa: f32,
168    color: [f32; 4],
169    dash: f32,
170    gap: f32,
171    phase: f32,
172) -> Vec<LineInstance> {
173    let dash = dash.max(0.5);
174    let gap = gap.max(0.0);
175    let period = dash + gap;
176    let dx = b[0] - a[0];
177    let dy = b[1] - a[1];
178    let len = (dx * dx + dy * dy).sqrt();
179    if len < 1e-3 {
180        return Vec::new();
181    }
182    let (ux, uy) = (dx / len, dy / len);
183    // March offset (always backward against the flow so dashes appear to move a→b).
184    let mut t = -(phase.rem_euclid(1.0) * period);
185    let mut out = Vec::new();
186    while t < len {
187        let s = t.max(0.0);
188        let e = (t + dash).min(len);
189        if e > s {
190            out.push(LineInstance::round(
191                [a[0] + ux * s, a[1] + uy * s],
192                [a[0] + ux * e, a[1] + uy * e],
193                half_width,
194                aa,
195                color,
196            ));
197        }
198        t += period;
199    }
200    out
201}
202
203/// **Light-tracer particles** zipping along an edge `a → b`: `n` evenly-spaced
204/// discs whose fractional position is `(i/n + phase) mod 1`, so they flow a→b as
205/// `phase` advances. Pure in `phase`. `radius`/`color` per particle.
206pub fn particles_on_edge(
207    a: [f32; 2],
208    b: [f32; 2],
209    n: usize,
210    radius: f32,
211    color: [f32; 4],
212    phase: f32,
213) -> Vec<CircleInstance> {
214    if n == 0 {
215        return Vec::new();
216    }
217    (0..n)
218        .map(|i| {
219            let f = ((i as f32 / n as f32) + phase).rem_euclid(1.0);
220            // Brighten toward the head of the trail (a fading comet look).
221            let head = 0.55 + 0.45 * f; // dim at the tail, bright at the front
222            let c = [color[0], color[1], color[2], (color[3] * head).clamp(0.0, 1.0)];
223            CircleInstance {
224                center: [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f],
225                radius: radius * (0.6 + 0.6 * f),
226                color: c,
227                aa: 1.0,
228            }
229        })
230        .collect()
231}
232
233// ───────────────────────────────────────────────────────────────────────────
234// Interaction: shockwave
235// ───────────────────────────────────────────────────────────────────────────
236
237/// A radial **shockwave** ring expanding from `center` as `t ∈ [0,1]` runs
238/// (0 = just fired, 1 = faded out): the ring grows `r0 → r0 + reach` while its
239/// alpha fades `1 → 0`. Pure in `t`. The neon-dashboard "click ripple".
240#[inline]
241pub fn shockwave_ring(center: [f32; 2], r0: f32, reach: f32, color: [f32; 3], t: f32) -> RingInstance {
242    let t = t.clamp(0.0, 1.0);
243    let radius = r0 + reach * t;
244    let thickness = (reach * 0.10).max(2.0);
245    RingInstance {
246        center,
247        radius,
248        inner: (radius - thickness).max(0.0),
249        color: [color[0], color[1], color[2], (1.0 - t).powf(1.4)],
250        aa: 2.0,
251    }
252}
253
254// ───────────────────────────────────────────────────────────────────────────
255// Bloom (CPU post-process)
256// ───────────────────────────────────────────────────────────────────────────
257
258/// A cheap separable **bloom** post-pass over a straight-RGBA8 [`Frame`]: extract
259/// the pixels brighter than `threshold` (`0..1` luma), blur them with a small
260/// box-blur of `radius` px (separable, two passes), and **add** that light back
261/// onto the frame scaled by `intensity`. Intersecting neon lines therefore bleed
262/// and mix into colored "explosions" — the spec's bloom, with no GPU.
263///
264/// Pure, in-place, deterministic. `radius` is clamped to a small range so a wasm
265/// frame stays snappy. A no-op when `intensity <= 0` or `radius == 0`.
266pub fn bloom_composite(frame: &mut Frame, threshold: f32, radius: u32, intensity: f32) {
267    if intensity <= 0.0 || radius == 0 || frame.width == 0 || frame.height == 0 {
268        return;
269    }
270    let (w, h) = (frame.width as usize, frame.height as usize);
271    let radius = radius.min(8) as i32; // keep the blur cheap (wasm-safe)
272    let n = w * h;
273
274    // 1) Bright-pass: keep only pixels whose luma exceeds the threshold.
275    let mut bright = vec![0f32; n * 3];
276    for (i, px) in frame.rgba.chunks_exact(4).enumerate() {
277        let r = px[0] as f32 / 255.0;
278        let g = px[1] as f32 / 255.0;
279        let b = px[2] as f32 / 255.0;
280        let luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
281        if luma > threshold {
282            // soft knee so the bloom ramps in rather than hard-clips
283            let k = ((luma - threshold) / (1.0 - threshold).max(1e-3)).clamp(0.0, 1.0);
284            bright[i * 3] = r * k;
285            bright[i * 3 + 1] = g * k;
286            bright[i * 3 + 2] = b * k;
287        }
288    }
289
290    // 2) Separable box-blur (horizontal then vertical) of the bright layer.
291    let win = (2 * radius + 1) as f32;
292    let mut tmp = vec![0f32; n * 3];
293    // horizontal
294    for y in 0..h {
295        for x in 0..w {
296            let mut acc = [0f32; 3];
297            for d in -radius..=radius {
298                let sx = (x as i32 + d).clamp(0, w as i32 - 1) as usize;
299                let si = (y * w + sx) * 3;
300                acc[0] += bright[si];
301                acc[1] += bright[si + 1];
302                acc[2] += bright[si + 2];
303            }
304            let di = (y * w + x) * 3;
305            tmp[di] = acc[0] / win;
306            tmp[di + 1] = acc[1] / win;
307            tmp[di + 2] = acc[2] / win;
308        }
309    }
310    // vertical (back into `bright`)
311    for y in 0..h {
312        for x in 0..w {
313            let mut acc = [0f32; 3];
314            for d in -radius..=radius {
315                let sy = (y as i32 + d).clamp(0, h as i32 - 1) as usize;
316                let si = (sy * w + x) * 3;
317                acc[0] += tmp[si];
318                acc[1] += tmp[si + 1];
319                acc[2] += tmp[si + 2];
320            }
321            let di = (y * w + x) * 3;
322            bright[di] = acc[0] / win;
323            bright[di + 1] = acc[1] / win;
324            bright[di + 2] = acc[2] / win;
325        }
326    }
327
328    // 3) Additive composite the blurred light back onto the frame.
329    for (i, px) in frame.rgba.chunks_exact_mut(4).enumerate() {
330        for k in 0..3 {
331            let base = px[k] as f32 / 255.0;
332            let add = bright[i * 3 + k] * intensity;
333            px[k] = ((base + add) * 255.0).round().clamp(0.0, 255.0) as u8;
334        }
335        // bloom never makes a transparent pixel opaque on its own, but it lifts
336        // alpha where it added real light so the glow is visible over the ground.
337        if px[3] == 0 {
338            let lit = bright[i * 3] + bright[i * 3 + 1] + bright[i * 3 + 2];
339            if lit * intensity > 0.02 {
340                px[3] = (lit * intensity * 255.0).round().clamp(0.0, 255.0) as u8;
341            }
342        }
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    /// INJECT-ASSERT: the 2-stop / multi-stop ramp lands on the right colours at
351    /// the ends and blends in the middle (the gradient-by-scalar primitive).
352    #[test]
353    fn gradient_by_scalar_ramps_and_clamps() {
354        let stops = [(0.0, [0.0, 0.0, 1.0, 1.0]), (1.0, [1.0, 0.0, 0.0, 1.0])];
355        assert_eq!(gradient_by_scalar(&stops, -1.0), stops[0].1, "clamps below");
356        assert_eq!(gradient_by_scalar(&stops, 2.0), stops[1].1, "clamps above");
357        let mid = gradient_by_scalar(&stops, 0.5);
358        assert!((mid[0] - 0.5).abs() < 1e-5 && (mid[2] - 0.5).abs() < 1e-5, "mid blends: {mid:?}");
359        // A three-stop ramp picks the right segment.
360        let three = [(0.0, [0.0; 4]), (0.5, [1.0, 1.0, 1.0, 1.0]), (1.0, [0.0; 4])];
361        assert!(gradient_by_scalar(&three, 0.25)[0] > 0.4, "rises to the mid stop");
362        assert!(gradient_by_scalar(&three, 0.75)[0] < 0.6, "falls past the mid stop");
363    }
364
365    /// INJECT-ASSERT: pulse/strobe are deterministic envelopes — the SAME phase
366    /// gives the SAME value (snapshot-stable), and they actually move.
367    #[test]
368    fn pulse_and_strobe_are_deterministic_envelopes() {
369        assert!(pulse(0.0).abs() < 1e-5, "pulse troughs at phase 0");
370        assert!((pulse(0.5) - 1.0).abs() < 1e-5, "pulse peaks at half-cycle");
371        assert_eq!(pulse(0.25), pulse(0.25), "deterministic");
372        assert_eq!(strobe(0.05, 0.5), 1.0, "strobe lit in the duty window");
373        assert_eq!(strobe(0.75, 0.5), 0.0, "strobe dark past the duty window");
374        assert_eq!(strobe(1.05, 0.5), 1.0, "strobe is periodic (phase wraps)");
375    }
376
377    /// INJECT-ASSERT: the animated dash marches — at a different phase the lit
378    /// segments sit at different offsets (a real flow, not a static dash).
379    #[test]
380    fn dash_line_marches_with_phase() {
381        let a = [0.0, 0.0];
382        let b = [100.0, 0.0];
383        let d0 = dash_line(a, b, 2.0, 1.0, [1.0; 4], 8.0, 6.0, 0.0);
384        let d1 = dash_line(a, b, 2.0, 1.0, [1.0; 4], 8.0, 6.0, 0.5);
385        assert!(!d0.is_empty() && !d1.is_empty(), "the dash produced segments");
386        // The pattern marches: the first lit segment ENDS at a different offset
387        // (its start may clamp to 0 at both phases, but the lit length shifts).
388        assert_ne!(d0[0].b[0], d1[0].b[0], "dashes moved along the edge with phase");
389        // Determinism: same phase, same geometry.
390        let d0b = dash_line(a, b, 2.0, 1.0, [1.0; 4], 8.0, 6.0, 0.0);
391        assert_eq!(d0[0].a, d0b[0].a, "same phase → same dash (start)");
392        assert_eq!(d0[0].b, d0b[0].b, "same phase → same dash (end)");
393    }
394
395    /// INJECT-ASSERT: particles ride the edge — at phase 0 they sit at i/n, and a
396    /// phase bump slides every particle along the segment by the same fraction.
397    #[test]
398    fn particles_flow_along_the_edge() {
399        let a = [0.0, 0.0];
400        let b = [200.0, 0.0];
401        let p0 = particles_on_edge(a, b, 4, 3.0, [1.0; 4], 0.0);
402        let p1 = particles_on_edge(a, b, 4, 3.0, [1.0; 4], 0.25);
403        assert_eq!(p0.len(), 4);
404        // The first particle starts at the tail and advances a quarter-edge.
405        assert!((p0[0].center[0] - 0.0).abs() < 1e-3, "particle 0 at the tail at phase 0");
406        assert!((p1[0].center[0] - 50.0).abs() < 1e-3, "particle 0 advanced to x=50 at phase 0.25");
407    }
408
409    /// INJECT-ASSERT: the shockwave grows + fades with t (a real ripple), staying
410    /// a valid annulus.
411    #[test]
412    fn shockwave_expands_and_fades() {
413        let early = shockwave_ring([50.0, 50.0], 10.0, 100.0, [1.0, 0.4, 0.9], 0.1);
414        let late = shockwave_ring([50.0, 50.0], 10.0, 100.0, [1.0, 0.4, 0.9], 0.9);
415        assert!(late.radius > early.radius, "the ring expands with t");
416        assert!(late.color[3] < early.color[3], "and fades out");
417        assert!(early.inner < early.radius, "valid annulus");
418    }
419
420    /// INJECT-ASSERT: node_decor emits a shadow + a glow under each node marker
421    /// (one of each per drawable quad), AND the glow carries the node's colour.
422    #[test]
423    fn node_decor_emits_shadow_and_glow_per_node() {
424        let q = QuadInstance {
425            center: [40.0, 40.0],
426            radius: 8.0,
427            inner: 2.0,
428            color: [0.2, 0.8, 1.0, 1.0],
429            aa: 1.0,
430            shape: shape::SQUARE,
431            _pad: [0.0, 0.0],
432        };
433        let (shadows, glows) = node_decor(&[q], 0.5, 0.4);
434        assert_eq!(shadows.len(), 1, "one AO shadow under the node");
435        assert_eq!(glows.len(), 1, "one glow halo behind the node");
436        // The glow is the node's hue at the requested intensity; the shadow is dark.
437        assert!((glows[0].color[2] - 1.0).abs() < 1e-5, "glow carries the node blue");
438        assert!((glows[0].color[3] - 0.5).abs() < 1e-5, "glow at the requested intensity");
439        assert!(shadows[0].color[0] < 0.05, "shadow is black");
440        assert!(glows[0].radius > q.radius, "glow is larger than the marker (a bleed)");
441    }
442
443    /// INJECT-ASSERT: bloom actually spreads light — a single bright pixel on a
444    /// dark frame lights up its neighbours after the pass (and the source stays
445    /// lit), proving the box-blur bleed ran (not "didn't panic").
446    #[test]
447    fn bloom_spreads_light_to_neighbours() {
448        let (w, h) = (9u32, 9u32);
449        let mut rgba = vec![0u8; (w * h * 4) as usize];
450        // One opaque white pixel dead centre on a transparent black ground.
451        let c = ((4 * w + 4) * 4) as usize;
452        rgba[c..c + 4].copy_from_slice(&[255, 255, 255, 255]);
453        let mut frame = Frame { width: w, height: h, rgba };
454
455        let neighbour = ((4 * w + 5) * 4) as usize; // one px to the right
456        assert_eq!(frame.rgba[neighbour], 0, "neighbour starts dark");
457        bloom_composite(&mut frame, 0.5, 2, 1.0);
458        assert!(frame.rgba[neighbour] > 0, "bloom bled light onto the neighbour");
459        assert!(frame.rgba[neighbour + 3] > 0, "and lifted its alpha so the glow shows");
460        // The original bright pixel stays lit.
461        assert!(frame.rgba[c] > 200, "the source pixel stays bright");
462    }
463
464    /// INJECT-ASSERT: bloom is a no-op when disabled (intensity 0 / radius 0) — the
465    /// frame is byte-identical, so toggling bloom off truly removes it.
466    #[test]
467    fn bloom_disabled_is_a_noop() {
468        let (w, h) = (5u32, 5u32);
469        let mut rgba = vec![0u8; (w * h * 4) as usize];
470        rgba[((2 * w + 2) * 4) as usize..((2 * w + 2) * 4) as usize + 4]
471            .copy_from_slice(&[255, 255, 255, 255]);
472        let before = rgba.clone();
473        let mut frame = Frame { width: w, height: h, rgba };
474        bloom_composite(&mut frame, 0.5, 0, 1.0);
475        assert_eq!(frame.rgba, before, "radius 0 → no-op");
476        bloom_composite(&mut frame, 0.5, 2, 0.0);
477        assert_eq!(frame.rgba, before, "intensity 0 → no-op");
478    }
479}