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");
}
}