Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-demo/src/pop_beauty.rs

//! **The population BEAUTY layer** — the "Cyber-Toon Raytraced" art direction
//! (`.cache/claude-build/pop-graph-aesthetic.md`) applied to the Sverige graph.
//!
//! This is the population-*specific* skin over the **domain-agnostic** engine
//! helpers in [`facett_core::render::decor`] (glow / AO / dash / particles /
//! shockwave / bloom / gradient-by-scalar). It owns:
//!
//! * [`NEON`] — the 12-label neon gradient palette (per the spec table): a
//!   `(lo, hi, anim)` per label so a node fills with its colour and the
//!   enforcement labels **pulse**, PoliceRecord **strobes**.
//! * [`PopClock`] — the **injected clock** (a phase in cycles) that drives every
//!   animation (pulse / strobe / dash march / particle flow / shockwave). A test
//!   sets it to a fixed value so snapshots are deterministic (the LAW).
//! * [`edge_style`] — maps a relation name to its rendered style (neon-blue
//!   ribbon under nodes · jagged red animated dash toward KfmCase · liquid
//!   golden/purple flow, drawn with the shared [`decor::GLOW_LADDER`] glow-stroke
//!   ladder: a wide faint bleed under a white-hot core, the same primitive korp's
//!   golden case trail is painted with).
//! * [`Selection`] — the click-shockwave state: a selected Person + the set of its
//!   highlighted relations, and the dimmed-alpha applied to everything unrelated.
//! * [`render_scene`] — the single render path: lowers the model to L0, layers the
//!   decor (AO shadows → glow halos → markers), styles edges by relation, adds the
//!   animated dash + particle tracers + shockwave, dims the unrelated subgraph when
//!   a node is selected, and applies the CPU **bloom** post-pass. With the
//!   `l1-vello` feature on, the same scene is *elevated* by an L1 gradient/glow
//!   overlay; without it (the wasm demo) the CPU lane stands alone and still looks
//!   great.

use facett_core::render::{
    Camera as CoreCamera, CpuRenderer, Frame, LineInstance, QuadInstance, Renderer, RingInstance,
    decor, prim::shape,
};
use facett_graphview::{Camera, Decorations, GraphModel, lower_to_l0};

use crate::pop_model::{ColorMode, PopQuery};

/// One label's neon gradient + animation class (the spec's palette table).
#[derive(Clone, Copy, Debug)]
pub struct Neon {
    /// The low gradient stop (straight RGBA, `0..1`).
    pub lo: [f32; 4],
    /// The high gradient stop.
    pub hi: [f32; 4],
    /// The animation envelope this label runs.
    pub anim: Anim,
}

/// How a label's glow animates (injected-clock driven).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Anim {
    /// Steady glow (most labels).
    Steady,
    /// Crimson→neon-pink **pulse** (Debt / KfmCase — high-stakes).
    Pulse,
    /// Hazard-yellow **strobe** (PoliceRecord — high visual weight).
    Strobe,
}

#[inline]
const fn rgba(r: u8, g: u8, b: u8) -> [f32; 4] {
    [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]
}

/// The **12-label neon gradient palette** keyed by label index (parallel to
/// `knut_popsim::LABELS`), straight off the aesthetic spec table.
pub const NEON: [Neon; 12] = [
    // Person — Electric Cyan → Ice Blue (the anchor hub), polished glass.
    Neon { lo: rgba(0x18, 0xE0, 0xFF), hi: rgba(0x9B, 0xE7, 0xFF), anim: Anim::Steady },
    // Address — Deep Emerald → Mint.
    Neon { lo: rgba(0x0A, 0xC2, 0x6B), hi: rgba(0x86, 0xFF, 0xC4), anim: Anim::Steady },
    // Company — Bright Tangerine Orange (sharp metallic).
    Neon { lo: rgba(0xFF, 0x8A, 0x1E), hi: rgba(0xFF, 0xC4, 0x6B), anim: Anim::Steady },
    // Debt — Crimson Red → Neon Pink PULSE.
    Neon { lo: rgba(0xE3, 0x12, 0x4B), hi: rgba(0xFF, 0x4D, 0xD8), anim: Anim::Pulse },
    // Loan — Rich Gold → Lime.
    Neon { lo: rgba(0xFF, 0xC8, 0x1E), hi: rgba(0xC6, 0xFF, 0x3D), anim: Anim::Steady },
    // TaxRecord — Deep Royal Purple (stable low-intensity).
    Neon { lo: rgba(0x6A, 0x2C, 0xE0), hi: rgba(0xB4, 0x8C, 0xFF), anim: Anim::Steady },
    // KfmCase — Crimson → Neon Pink PULSE (enforcement).
    Neon { lo: rgba(0xFF, 0x1E, 0x57), hi: rgba(0xFF, 0x66, 0xE0), anim: Anim::Pulse },
    // PoliceRecord — Hazard Yellow + Dark Amber STROBE.
    Neon { lo: rgba(0xFF, 0xE0, 0x12), hi: rgba(0xFF, 0x8A, 0x00), anim: Anim::Strobe },
    // FkBenefit — Vibrant Magenta → Indigo.
    Neon { lo: rgba(0xFF, 0x3D, 0xCB), hi: rgba(0x5B, 0x4B, 0xFF), anim: Anim::Steady },
    // AfRecord — Magenta → Indigo (work, sibling of FkBenefit).
    Neon { lo: rgba(0xE0, 0x4D, 0xFF), hi: rgba(0x4B, 0x6B, 0xFF), anim: Anim::Steady },
    // Vehicle — Tangerine (commercial, sibling of Company).
    Neon { lo: rgba(0xFF, 0x7A, 0x2E), hi: rgba(0xFF, 0xB0, 0x52), anim: Anim::Steady },
    // Property — Deep Emerald → Mint (geography, sibling of Address).
    Neon { lo: rgba(0x12, 0xC9, 0x88), hi: rgba(0x9B, 0xFF, 0xD8), anim: Anim::Steady },
];

/// The matte-slate scene ground from the spec (`#0A0B0E`), straight RGBA8.
pub const SLATE_BG: [u8; 4] = [0x0A, 0x0B, 0x0E, 255];

/// The **injected clock** — an animation phase in *cycles* (`1.0` = one loop). All
/// pulse/strobe/dash/particle/shockwave timing derives from this single value, so a
/// test fixes it and the rendered picture is byte-stable (the determinism LAW). The
/// live app advances `phase += dt * RATE` each frame; a snapshot sets it explicitly.
#[derive(Clone, Copy, Debug)]
pub struct PopClock {
    /// The current animation phase, in cycles.
    pub phase: f32,
}

impl Default for PopClock {
    fn default() -> Self {
        Self { phase: 0.0 }
    }
}

impl PopClock {
    /// A clock pinned to a fixed phase (for deterministic snapshots).
    pub fn at(phase: f32) -> Self {
        Self { phase }
    }
    /// Advance by `dt` seconds at the showcase animation rate (one full pulse cycle
    /// per ~2.2s — a calm breathing glow). The live app calls this each frame.
    pub fn tick(&mut self, dt: f32) {
        self.phase = (self.phase + dt / 2.2).rem_euclid(1_000_000.0);
    }
}

/// The rendered edge style for a relation (the spec's three edge families).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EdgeStyle {
    /// `LIVES_AT` / `REGISTERED_AT` — soft thick neon-blue ribbon woven UNDER nodes.
    Ribbon,
    /// `OWES` / `ESCALATES_TO` — jagged red animated dash flowing toward KfmCase,
    /// **with particle tracers** (the money/debt flow).
    DebtFlow,
    /// `RECEIVES` / `HOLDS` — liquid golden/purple flow (a smooth high-gloss tie).
    Liquid,
    /// Everything else — a faint neutral tie.
    Faint,
}

/// Classify a relation name into its rendered [`EdgeStyle`] (the spec's mapping).
pub fn edge_style(rel: &str) -> EdgeStyle {
    match rel {
        "LIVES_AT" | "REGISTERED_AT" => EdgeStyle::Ribbon,
        "OWES" | "ESCALATES_TO" | "SUBJECT_OF" => EdgeStyle::DebtFlow,
        "RECEIVES" | "HOLDS" => EdgeStyle::Liquid,
        _ => EdgeStyle::Faint,
    }
}

/// The neon colour for a relation's edge (the head colour of its style family).
fn edge_color(style: EdgeStyle) -> [f32; 4] {
    match style {
        EdgeStyle::Ribbon => [0.20, 0.62, 1.00, 0.55],   // neon blue
        EdgeStyle::DebtFlow => [1.00, 0.18, 0.34, 0.95],  // crimson
        EdgeStyle::Liquid => [1.00, 0.80, 0.20, 0.80],    // gold
        EdgeStyle::Faint => [0.55, 0.62, 0.78, 0.30],     // neutral
    }
}

/// The **selection / shockwave** interaction state — which Person node is selected
/// (its id) and the radial-shockwave progress `t ∈ [0,1]` since the click. When a
/// node is selected, everything not in its immediate connection set dims to
/// [`DIM_ALPHA`]; the selected node + its `OWES`/`SUBJECT_OF`/… connections light up.
#[derive(Clone, Debug, Default)]
pub struct Selection {
    /// The selected node's id (`None` = nothing selected, full graph lit).
    pub node: Option<String>,
    /// The set of node ids that stay LIT (the selected node + its neighbours).
    pub lit: std::collections::HashSet<String>,
    /// The relation names highlighted by the selection (the spec's "neon dashboard").
    pub rels: std::collections::HashSet<String>,
}

/// The alpha unrelated subgraphs dim to when a node is selected (the spec's 0.1).
pub const DIM_ALPHA: f32 = 0.10;

impl Selection {
    /// Build the selection set for a clicked node id over a model: the node itself
    /// plus every node one edge away, and the relations on those edges. Domain-light
    /// (it only reads ids + edge endpoints), so it works for any view's model.
    pub fn for_node(node: &str, model: &GraphModel) -> Self {
        let mut lit = std::collections::HashSet::new();
        let mut rels = std::collections::HashSet::new();
        lit.insert(node.to_string());
        for e in &model.edges {
            if e.from == node {
                lit.insert(e.to.clone());
                if let Some(l) = &e.label {
                    rels.insert(l.clone());
                }
            } else if e.to == node {
                lit.insert(e.from.clone());
                if let Some(l) = &e.label {
                    rels.insert(l.clone());
                }
            }
        }
        Self { node: Some(node.to_string()), lit, rels }
    }

    /// Is this node id lit (selected or a neighbour)? Always true when nothing is
    /// selected (the whole graph is lit).
    pub fn is_lit(&self, id: &str) -> bool {
        self.node.is_none() || self.lit.contains(id)
    }

    /// The alpha multiplier for a node/edge: 1.0 when lit, [`DIM_ALPHA`] when a
    /// selection is active and this id is not in the lit set.
    pub fn alpha_for(&self, id: &str) -> f32 {
        if self.is_lit(id) { 1.0 } else { DIM_ALPHA }
    }
}

/// Per-label glow intensity at this clock phase: pulse for Debt/KfmCase, strobe for
/// PoliceRecord, a calm steady glow otherwise. Pure in `clock`.
pub fn label_glow_intensity(label: u8, clock: PopClock) -> f32 {
    match NEON[label as usize].anim {
        Anim::Steady => 0.34,
        // breathe between a dim base and a hot peak
        Anim::Pulse => 0.30 + 0.55 * decor::pulse(clock.phase),
        // hard hazard flash
        Anim::Strobe => 0.25 + 0.65 * decor::strobe(clock.phase, 0.35),
    }
}

/// The gradient-by-scalar **fill** for a node under the active colour mode: in
/// by-label mode the node breathes between its neon lo→hi stops (the glass sheen),
/// modulated by its pulse/strobe; otherwise the model's own fill is kept (kommun /
/// debt-heat already encode the scalar). Returns straight RGBA `0..1`.
pub fn node_fill(label: u8, base: [f32; 4], mode: ColorMode, clock: PopClock) -> [f32; 4] {
    match mode {
        ColorMode::ByLabel => {
            let neon = NEON[label as usize];
            // The glass sheen sweeps lo→hi with the label's animation envelope.
            let t = match neon.anim {
                Anim::Steady => 0.5,
                Anim::Pulse => decor::pulse(clock.phase),
                Anim::Strobe => decor::strobe(clock.phase, 0.35),
            };
            let mut c = decor::lerp_rgba(neon.lo, neon.hi, t);
            c[3] = base[3];
            c
        }
        _ => base,
    }
}

/// The full render context for one view (what the live UI + the headless test both
/// pass to [`render_scene`]).
pub struct SceneCtx<'a> {
    pub model: &'a GraphModel,
    pub deco: &'a Decorations,
    pub camera: &'a Camera,
    pub query: &'a PopQuery,
    pub clock: PopClock,
    pub selection: &'a Selection,
    /// Whether the CPU bloom post-pass runs (the spec's viewport bloom toggle).
    pub bloom: bool,
    /// The map from a node id to its label index, so the renderer can pick the neon
    /// palette + animation per node (the model only carries colours + ids).
    pub label_of: &'a std::collections::HashMap<String, u8>,
}

/// **The single render path** — lower `ctx.model` to L0, layer the cyber-toon decor
/// (AO shadows → glow halos → glossy markers), style edges by relation (ribbon /
/// animated debt-flow dash + particles / liquid flow), dim the unrelated subgraph
/// when a node is selected, fire the click shockwave, and apply the CPU bloom
/// post-pass. Returns the straight-RGBA8 [`Frame`] (the same picture the UI shows
/// and the PNG-blob test reads back).
pub fn render_scene(ctx: &SceneCtx, w: u32, h: u32) -> Frame {
    let (mut quads, base_lines) = lower_to_l0(ctx.model, ctx.deco, ctx.camera);

    // ── Recolour + re-alpha node markers: neon gradient fill by label (with the
    // pulse/strobe sheen), dimmed when a selection excludes them. The marker order
    // mirrors the model's node order (lower_to_l0 keeps it; rings come after). ──
    let mut node_iter = ctx.model.nodes.iter();
    for q in quads.iter_mut() {
        if q.shape == shape::SQUARE && let Some(n) = node_iter.next() {
            let label = ctx.label_of.get(&n.id).copied().unwrap_or(0);
            let mut fill = node_fill(label, q.color, ctx.query.color, ctx.clock);
            fill[3] *= ctx.selection.alpha_for(&n.id);
            q.color = fill;
        }
    }

    // ── Build the edge lanes by relation style. We rebuild from the model (not the
    // lowered base_lines) so each edge gets its family's geometry + animation. ──
    let idx: std::collections::HashMap<&str, (f32, f32)> =
        ctx.model.nodes.iter().map(|n| (n.id.as_str(), ctx.camera.project(n.pos))).collect();

    let mut ribbons: Vec<LineInstance> = Vec::new();
    let mut flows: Vec<LineInstance> = Vec::new();
    let mut liquid: Vec<LineInstance> = Vec::new();
    let mut faint: Vec<LineInstance> = Vec::new();
    let mut particles: Vec<QuadInstance> = Vec::new();

    for e in &ctx.model.edges {
        let (Some(&a), Some(&b)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) else {
            continue;
        };
        let a = [a.0, a.1];
        let b = [b.0, b.1];
        let style = e.label.as_deref().map(edge_style).unwrap_or(EdgeStyle::Faint);
        let lit = ctx.selection.is_lit(&e.from) && ctx.selection.is_lit(&e.to);
        let dim = if lit { 1.0 } else { DIM_ALPHA };
        let mut col = edge_color(style);
        col[3] *= dim;
        match style {
            EdgeStyle::Ribbon => {
                // soft thick ribbon (drawn first → woven UNDER the nodes)
                ribbons.push(LineInstance::round(a, b, 3.2, 2.0, col));
            }
            EdgeStyle::DebtFlow => {
                // jagged red animated dash marching a→b (debt flowing to enforcement)
                flows.extend(decor::dash_line(a, b, 1.8, 1.0, col, 9.0, 7.0, ctx.clock.phase));
                // particle tracers along the LIT debt edges (the money moving)
                if lit {
                    let p = decor::particles_on_edge(a, b, 4, 2.4, [1.0, 0.5, 0.85, dim], ctx.clock.phase);
                    particles.extend(p.into_iter().map(|c| c.lower()));
                }
            }
            EdgeStyle::Liquid => {
                // **THE GLOW-STROKE LADDER** (`decor::GLOW_LADDER`, the shared primitive
                // korp's golden case trail is also drawn with): the liquid money edge is
                // not one stroke but the whole ladder — a wide faint bleed, a mid halo
                // and a white-hot core — laid widest-first so the core lands on top.
                // Drawn on the DARK pop pane, so `GlowGround::Dark`.
                for (w, c) in decor::glow_stroke_passes_on(
                    2.4,
                    [col[0], col[1], col[2]],
                    decor::GlowGround::Dark,
                ) {
                    liquid.push(LineInstance::round(a, b, w, 1.5, [c[0], c[1], c[2], c[3] * dim]));
                }
                if lit {
                    let p = decor::particles_on_edge(a, b, 3, 2.0, [1.0, 0.85, 0.35, dim], ctx.clock.phase);
                    particles.extend(p.into_iter().map(|c| c.lower()));
                }
            }
            EdgeStyle::Faint => faint.push(LineInstance::round(a, b, 1.0, 1.0, col)),
        }
    }

    // ── Node decor (AO drop-shadow + glow halo) only for the LIT, drawable markers,
    // with the per-label animated glow intensity. ──
    let mut shadows: Vec<QuadInstance> = Vec::new();
    let mut glows: Vec<QuadInstance> = Vec::new();
    let mut node_iter2 = ctx.model.nodes.iter();
    for q in quads.iter() {
        if q.shape != shape::SQUARE {
            continue;
        }
        let Some(n) = node_iter2.next() else { break };
        let label = ctx.label_of.get(&n.id).copied().unwrap_or(0);
        let lit = ctx.selection.alpha_for(&n.id);
        let r = q.radius;
        shadows.push(decor::ao_shadow(q.center, r, 0.40 * lit).lower());
        let intensity = label_glow_intensity(label, ctx.clock) * lit;
        glows.push(
            decor::glow_halo(q.center, r, [q.color[0], q.color[1], q.color[2]], intensity, 2.2).lower(),
        );
    }

    // ── Shockwave: a radial ring expanding from the selected node. ──
    let mut shock: Vec<QuadInstance> = Vec::new();
    if let Some(sel) = &ctx.selection.node && let Some(n) = ctx.model.nodes.iter().find(|n| &n.id == sel) {
        let c = ctx.camera.project(n.pos);
        // the shockwave t loops with the clock (so it keeps rippling while selected)
        let t = ctx.clock.phase.rem_euclid(1.0);
        let ring: RingInstance = decor::shockwave_ring([c.0, c.1], 14.0, 120.0, [0.18, 0.95, 1.0], t);
        shock.push(ring.lower());
    }

    // ── Compose: ground grid faint ties → ribbons (under) → flows → liquid →
    // shadows → glows → markers → particles → shockwave. ──
    let core_cam = CoreCamera {
        pan_x: ctx.camera.pan_x,
        pan_y: ctx.camera.pan_y,
        zoom: ctx.camera.zoom,
        ..CoreCamera::default()
    };
    let mut r = CpuRenderer::new(SLATE_BG);
    let canvas = r.begin(w, h, core_cam);
    canvas.push_lines(&faint);
    canvas.push_lines(&ribbons);
    canvas.push_lines(&flows);
    canvas.push_lines(&liquid);
    let _ = base_lines; // styled lanes replace the flat base lines
    canvas.push_quads(&shadows);
    canvas.push_quads(&glows);
    canvas.push_quads(&quads);
    canvas.push_quads(&particles);
    canvas.push_quads(&shock);
    let mut frame = r.present();

    // ── CPU bloom post-pass: bright neon bleeds + intersecting lines mix. ──
    if ctx.bloom {
        decor::bloom_composite(&mut frame, 0.55, 3, 0.65);
    }

    // ── L1 vello ELEVATION (feature `l1-vello`, desktop GPU only): a true
    // gradient-filled glossy sheen + gaussian glow over the lit node markers,
    // composited on top of the CPU base. Never required — without the feature (the
    // wasm demo) the CPU lane above stands alone and already looks great. ──
    #[cfg(feature = "l1-vello")]
    {
        let overlay = l1_overlay(ctx);
        if !overlay.is_empty() {
            let (composited, _drawn) =
                facett_core::render::l1::compose_l0_l1(frame, &overlay);
            return composited;
        }
    }

    frame
}

/// Build the **L1 vello overlay** for a scene (feature `l1-vello`): a gradient-
/// filled rounded rect (the glossy glass sheen, the node's neon lo→hi stops) + a
/// soft gaussian glow per LIT node marker. Screen-pixel coords, so it registers
/// exactly over the L0 markers. Domain-agnostic helpers (`GradientRect`/`Glow`) from
/// the engine — this just feeds them the population's neon palette + positions.
#[cfg(feature = "l1-vello")]
pub fn l1_overlay(ctx: &SceneCtx) -> facett_core::render::l1::L1Overlay {
    use facett_core::render::l1::{GradientRect, Glow, L1Overlay};
    let mut gradient_rects = Vec::new();
    let mut glows = Vec::new();
    for n in &ctx.model.nodes {
        if !ctx.selection.is_lit(&n.id) {
            continue; // only elevate the lit subgraph (dimmed nodes stay on L0)
        }
        let (cx, cy) = ctx.camera.project(n.pos);
        let label = ctx.label_of.get(&n.id).copied().unwrap_or(0);
        let neon = NEON[label as usize];
        // The marker half-extent ≈ the L0 chip size scaled by the camera (matches
        // facett-graphview's BOX_H lowering: base_r = BOX_H*0.5*zoom).
        let r = (17.0 * ctx.camera.zoom).max(4.0);
        gradient_rects.push(GradientRect {
            min: [cx - r, cy - r],
            max: [cx + r, cy + r],
            corner: r * 0.4,
            color0: neon.lo,
            color1: neon.hi,
        });
        let intensity = label_glow_intensity(label, ctx.clock);
        glows.push(Glow {
            min: [cx - r * 1.6, cy - r * 1.6],
            max: [cx + r * 1.6, cy + r * 1.6],
            corner: r,
            color: [neon.hi[0], neon.hi[1], neon.hi[2], intensity],
            std_dev: r * 0.9,
        });
    }
    L1Overlay { gradient_rects, curves: Vec::new(), glows }
}

#[cfg(test)]
mod tests {
    use super::*;
    use facett_graphview::{Color, GraphEdge, GraphNode, Pos};
    use std::collections::HashMap;

    fn model() -> (GraphModel, HashMap<String, u8>) {
        // A tiny Person(0) → Debt(3) → KfmCase(6) chain + an unrelated Person(0).
        let nodes = vec![
            GraphNode { id: "p0".into(), label: "Person".into(), fill: Color::rgb(20, 220, 255), stroke: Color::WHITE, pos: Pos::new(0.0, 0.0) },
            GraphNode { id: "d0".into(), label: "Debt".into(), fill: Color::rgb(227, 18, 75), stroke: Color::WHITE, pos: Pos::new(200.0, 0.0) },
            GraphNode { id: "k0".into(), label: "KfmCase".into(), fill: Color::rgb(255, 30, 87), stroke: Color::WHITE, pos: Pos::new(400.0, 0.0) },
            GraphNode { id: "p9".into(), label: "Person".into(), fill: Color::rgb(20, 220, 255), stroke: Color::WHITE, pos: Pos::new(0.0, 300.0) },
        ];
        let edges = vec![
            GraphEdge { from: "p0".into(), to: "d0".into(), color: Color::rgba(255, 138, 101, 200), dashed: false, label: Some("OWES".into()) },
            GraphEdge { from: "d0".into(), to: "k0".into(), color: Color::rgba(229, 57, 53, 200), dashed: true, label: Some("ESCALATES_TO".into()) },
        ];
        let label_of: HashMap<String, u8> =
            [("p0", 0u8), ("d0", 3), ("k0", 6), ("p9", 0)].iter().map(|(k, v)| (k.to_string(), *v)).collect();
        (GraphModel { nodes, edges }, label_of)
    }

    /// INJECT-ASSERT: the neon palette covers all 12 labels and the enforcement
    /// labels pulse / PoliceRecord strobes (the spec table is wired, not decorative).
    #[test]
    fn neon_palette_classifies_animations() {
        assert_eq!(NEON.len(), 12);
        assert_eq!(NEON[3].anim, Anim::Pulse, "Debt pulses");
        assert_eq!(NEON[6].anim, Anim::Pulse, "KfmCase pulses");
        assert_eq!(NEON[7].anim, Anim::Strobe, "PoliceRecord strobes");
        assert_eq!(NEON[0].anim, Anim::Steady, "Person is a steady anchor");
        // Person is cyan-ish (more blue/green than red).
        assert!(NEON[0].lo[2] > NEON[0].lo[0], "Person lo stop is cyan");
    }

    /// INJECT-ASSERT: the relation→style map matches the spec's three families.
    #[test]
    fn edge_styles_map_per_spec() {
        assert_eq!(edge_style("LIVES_AT"), EdgeStyle::Ribbon);
        assert_eq!(edge_style("REGISTERED_AT"), EdgeStyle::Ribbon);
        assert_eq!(edge_style("OWES"), EdgeStyle::DebtFlow);
        assert_eq!(edge_style("ESCALATES_TO"), EdgeStyle::DebtFlow);
        assert_eq!(edge_style("RECEIVES"), EdgeStyle::Liquid);
        assert_eq!(edge_style("HOLDS"), EdgeStyle::Liquid);
        assert_eq!(edge_style("DRIVES"), EdgeStyle::Faint);
    }

    /// INJECT-ASSERT: selecting a node lights it + its neighbours and dims the rest;
    /// the highlighted relation set carries the click's connections.
    #[test]
    fn selection_lights_neighbours_dims_rest() {
        let (m, _) = model();
        let sel = Selection::for_node("p0", &m);
        assert!(sel.is_lit("p0"), "selected node lit");
        assert!(sel.is_lit("d0"), "its OWES neighbour lit");
        assert!(!sel.is_lit("p9"), "the unrelated person is dimmed");
        assert!((sel.alpha_for("p9") - DIM_ALPHA).abs() < 1e-6, "dimmed to 0.1");
        assert!((sel.alpha_for("p0") - 1.0).abs() < 1e-6, "selected stays full alpha");
        assert!(sel.rels.contains("OWES"), "OWES highlighted by the click");
        // No selection → everything lit.
        let none = Selection::default();
        assert!(none.is_lit("p9") && none.alpha_for("p9") == 1.0, "nothing selected → all lit");
    }

    /// INJECT-ASSERT: by-label fill breathes with the clock for a pulsing label —
    /// the SAME phase gives the SAME colour (deterministic), different phases differ.
    #[test]
    fn node_fill_breathes_deterministically() {
        let base = [0.5, 0.5, 0.5, 1.0];
        let c0 = node_fill(3, base, ColorMode::ByLabel, PopClock::at(0.0)); // Debt, trough
        let c1 = node_fill(3, base, ColorMode::ByLabel, PopClock::at(0.5)); // Debt, peak
        assert_ne!(c0, c1, "Debt fill breathes between its lo/hi neon stops");
        let c0b = node_fill(3, base, ColorMode::ByLabel, PopClock::at(0.0));
        assert_eq!(c0, c0b, "same phase → same fill (snapshot-stable)");
        // Non-by-label modes keep the model's own fill (kommun / debt-heat scalar).
        assert_eq!(node_fill(3, base, ColorMode::ByDebtHeat, PopClock::at(0.5)), base);
    }

    /// INJECT-ASSERT (the picture): render the debt chain to a non-blank frame on
    /// the dark slate ground, and assert a selection-active render lights FEWER
    /// pixels in the unrelated region (the dimmed subgraph) than an unselected one —
    /// the shockwave/dim is real, not a flag. PNG blobs emitted for eyeballing.
    #[test]
    fn render_scene_dims_unrelated_region_when_selected() {
        let (m, label_of) = model();
        let cam = Camera::fit(&m, 460.0, 440.0, 36.0);
        let deco = Decorations::default();
        let q = PopQuery::default();
        let clock = PopClock::at(0.5); // a fixed, deterministic phase

        // Summed-luma brightness over a frame region (sensitive to dimming, where a
        // bare lit-pixel count is not — a dimmed node still has non-zero alpha).
        let region_luma = |f: &Frame, x0: usize, x1: usize, y0: usize, y1: usize| -> u64 {
            let w = f.width as usize;
            let mut sum = 0u64;
            for y in y0..y1 {
                for x in x0..x1 {
                    let p = (y * w + x) * 4;
                    sum += f.rgba[p] as u64 + f.rgba[p + 1] as u64 + f.rgba[p + 2] as u64;
                }
            }
            sum
        };

        // Render WITHOUT bloom for the dim oracle (bloom bleed would flood the region
        // and mask the dim); bloom is asserted separately by the bloom unit test.
        let none = Selection::default();
        let ctx = SceneCtx { model: &m, deco: &deco, camera: &cam, query: &q, clock, selection: &none, bloom: false, label_of: &label_of };
        let unselected = render_scene(&ctx, 460, 440);
        assert!(unselected.lit_px() > 2_000, "the scene drew a luminous picture: {}", unselected.lit_px());
        assert_eq!(&unselected.rgba[0..4], &SLATE_BG, "corner is the matte slate ground");

        // Select p0 — the unrelated p9 dims to 0.1 (its glow + marker go faint).
        let sel = Selection::for_node("p0", &m);
        let ctx2 = SceneCtx { model: &m, deco: &deco, camera: &cam, query: &q, clock, selection: &sel, bloom: false, label_of: &label_of };
        let selected = render_scene(&ctx2, 460, 440);

        // Emit the blobs FIRST (before any assert) so they exist for eyeballing
        // regardless of the oracle outcome. Also emit the bloom'd hero pictures.
        if let Ok(dir) = std::env::var("POP_BLOB_DIR") {
            save_png(&format!("{dir}/scene_unselected.png"), &unselected);
            save_png(&format!("{dir}/scene_selected.png"), &selected);
            let ctx_bloom = SceneCtx { model: &m, deco: &deco, camera: &cam, query: &q, clock, selection: &none, bloom: true, label_of: &label_of };
            save_png(&format!("{dir}/scene_bloom.png"), &render_scene(&ctx_bloom, 460, 440));
        }

        // The whole-frame light DROPS when a node is selected (one of four nodes +
        // its glow + its edges dimmed to 0.1) — the shockwave/dim is real, not a flag.
        let total_unsel = region_luma(&unselected, 0, 460, 0, 440);
        let total_sel = region_luma(&selected, 0, 460, 0, 440);
        assert!(total_unsel > 0, "the scene emitted light");
        assert!(
            total_sel < total_unsel,
            "selecting dims the unrelated subgraph (less total light): {total_sel} < {total_unsel}"
        );
        // And the selected node itself stays bright — its connected subgraph lights up
        // (the selected picture is NOT uniformly darker; the lit core holds its light).
        let sel_state = selected.lit_px();
        assert!(sel_state > 1_000, "the lit selection core is still luminous: {sel_state}");
    }

    /// Write a straight-RGBA8 [`Frame`] to a real PNG (the blob seam; image dev-dep).
    pub(super) fn save_png(path: &str, frame: &Frame) {
        image::save_buffer(
            path,
            &frame.rgba,
            frame.width,
            frame.height,
            image::ColorType::Rgba8,
        )
        .expect("write png blob");
    }
}