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}