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

nordisk/facett

facett-graph3d/src/neon.rs

//! **Neon decor skin for [`Graph3D`](crate::Graph3D)** — thin, pure helpers that
//! lower the domain-agnostic [`facett_core::render::decor`] kernel (glow halos, AO
//! drop-shadows, particle tracers, shockwave rings, gradient-by-scalar ramps,
//! pulse/strobe envelopes) into **egui-painter** draw calls.
//!
//! The kernel emits L0 instances ([`RingInstance`](facett_core::render::RingInstance)
//! / [`CircleInstance`](facett_core::render::CircleInstance)); here we read their
//! geometry/colour and paint them as layered translucent rings/circles in the always
//! -available CPU painter lane (wasm-clean, no GPU). Every effect is a **pure
//! function of the injected clock phase**, so a pinned clock ⇒ a byte-stable frame
//! (the determinism LAW).

use egui::{Color32, Painter, Pos2, Stroke, pos2};
use facett_core::render::decor;

/// The focus dim: unrelated nodes/edges fade to this alpha when a node is selected
/// (the neon-dashboard "light the subgraph, dim the rest").
pub const DIM_ALPHA: f32 = 0.1;

/// Particles per lit edge (Full mode tracers).
const EDGE_PARTICLES: usize = 4;

/// The **NEON** multi-stop ramp keyed by a node scalar in `[0,1]` — cyan → azure →
/// violet → magenta → tangerine → lime. Keying each node by its normalised index
/// gives the lit, many-shade frame the snapshot proof asserts.
pub const NEON: &[(f32, [f32; 4])] = &[
    (0.00, [0.00, 0.95, 1.00, 1.0]), // cyan
    (0.20, [0.20, 0.50, 1.00, 1.0]), // azure
    (0.40, [0.60, 0.20, 1.00, 1.0]), // violet
    (0.60, [1.00, 0.20, 0.70, 1.0]), // magenta
    (0.80, [1.00, 0.60, 0.10, 1.0]), // tangerine
    (1.00, [0.60, 1.00, 0.20, 1.0]), // lime
];

/// Straight RGBA `[0,1]` → `Color32` (un-premultiplied, clamped).
#[inline]
pub fn rgba(c: [f32; 4]) -> Color32 {
    let f = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
    Color32::from_rgba_unmultiplied(f(c[0]), f(c[1]), f(c[2]), f(c[3]))
}

/// A `Color32` → straight RGB `[0,1]` triple (drops alpha; for kernel colour args).
#[inline]
pub fn rgb3(c: Color32) -> [f32; 3] {
    [c.r() as f32 / 255.0, c.g() as f32 / 255.0, c.b() as f32 / 255.0]
}

/// The neon hue for a node at normalised position `scalar ∈ [0,1]`.
#[inline]
pub fn neon_color(scalar: f32) -> Color32 {
    rgba(decor::gradient_by_scalar(NEON, scalar))
}

#[inline]
fn p2(p: Pos2) -> [f32; 2] {
    [p.x, p.y]
}

/// Paint a node's **glow halo + AO orb** under its marker — the lit-orb look. The
/// halo geometry comes from [`decor::glow_halo`] and the shadow from
/// [`decor::ao_shadow`]; we render them as layered translucent circles (a soft
/// additive bleed, no GPU blur). `alpha` is the node's focus alpha (dimmed nodes
/// get a fainter orb).
pub fn paint_node_orb(painter: &Painter, center: Pos2, r: f32, color: Color32, intensity: f32, alpha: f32) {
    // AO drop-shadow grounding the orb.
    let ao = decor::ao_shadow(p2(center), r, 0.35 * alpha);
    painter.circle_filled(pos2(ao.center[0], ao.center[1]), ao.radius, rgba(ao.color));
    // Glow halo: two layered translucent discs (wide+soft, then tighter+brighter).
    let halo = decor::glow_halo(p2(center), r, rgb3(color), intensity * alpha, 2.1);
    let [hr, hg, hb, ha] = halo.color;
    painter.circle_filled(center, halo.radius, rgba([hr, hg, hb, ha * 0.45]));
    painter.circle_filled(center, r * 1.4, rgba([hr, hg, hb, ha * 0.8]));
}

/// Paint **light-tracer particles** flowing `a → b` along a lit edge ([`decor::particles_on_edge`],
/// positions a pure function of `phase`).
pub fn paint_edge_particles(painter: &Painter, a: Pos2, b: Pos2, color: Color32, alpha: f32, phase: f32) {
    let col = [rgb3(color)[0], rgb3(color)[1], rgb3(color)[2], alpha];
    for c in decor::particles_on_edge(p2(a), p2(b), EDGE_PARTICLES, 1.6, col, phase) {
        painter.circle_filled(pos2(c.center[0], c.center[1]), c.radius, rgba(c.color));
    }
}

/// **Paint an EQ2-style glowing TRAIL along a polyline** — the [`decor::GLOW_LADDER`]
/// lowered onto the egui painter, plus the light-tracer particles that make the light
/// flow along it.
///
/// The ladder is the ONE writer of what a glow stroke is (`facett_core::render::decor`);
/// this is its painter projection, exactly as [`paint_node_orb`] is `glow_halo`'s. Every
/// pass is stroked over the WHOLE path before the next, narrower one — a per-segment
/// loop would lay a core over a neighbour's halo and the line would read as beads.
///
/// * `core_w` — the width of the innermost, hottest stroke, px.
/// * `phase` — the flow position, `0..1` ([`decor::glow_flow_phase`]); pure, so a pinned
///   clock gives a byte-stable frame. `None` paints no particles (a still trail).
///
/// Fewer than two points paints nothing.
pub fn paint_glow_trail(
    painter: &Painter,
    pts: &[Pos2],
    color: Color32,
    core_w: f32,
    phase: Option<f32>,
) {
    if pts.len() < 2 {
        return;
    }
    for (w, c) in decor::glow_stroke_passes(core_w, rgb3(color)) {
        // ONE pass over the whole path, widest first — never per-segment ladders.
        painter.add(egui::Shape::line(pts.to_vec(), Stroke::new(w, rgba(c))));
    }
    if let Some(phase) = phase {
        // The flow rides the SAME particle kernel the graph edges use, walked along the
        // path's segments so the tracers follow the curve rather than the chord.
        let col = {
            let [r, g, b] = rgb3(color);
            [r + (1.0 - r) * 0.6, g + (1.0 - g) * 0.6, b + (1.0 - b) * 0.6, 0.9]
        };
        for w in pts.windows(2) {
            for c in decor::particles_on_edge(p2(w[0]), p2(w[1]), 2, core_w * 0.9, col, phase) {
                painter.circle_filled(pos2(c.center[0], c.center[1]), c.radius, rgba(c.color));
            }
        }
    }
}

/// Paint a **shockwave ring** expanding from a clicked node ([`decor::shockwave_ring`],
/// pure in `t`). Drawn as a stroked annulus.
pub fn paint_shockwave(painter: &Painter, center: Pos2, reach: f32, color: Color32, t: f32) {
    let ring = decor::shockwave_ring(p2(center), 6.0, reach, rgb3(color), t);
    let w = (ring.radius - ring.inner).max(1.0);
    painter.circle_stroke(center, ring.radius, Stroke::new(w, rgba(ring.color)));
}

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

    #[test]
    fn neon_ramp_spans_many_hues() {
        // The ramp is not flat: ends differ and the middle blends (a real palette).
        let lo = neon_color(0.0);
        let hi = neon_color(1.0);
        assert_ne!(lo, hi, "neon ends differ");
        let mid = neon_color(0.5);
        assert_ne!(lo, mid, "neon ramp moves through the middle");
    }

    #[test]
    fn rgba_roundtrips_through_rgb3() {
        let c = Color32::from_rgb(51, 204, 255);
        let back = rgb3(c);
        assert!((back[0] - 0.2).abs() < 0.01 && (back[2] - 1.0).abs() < 0.01);
    }

    /// **The glow trail paints the whole LADDER, widest first — measured on PIXELS.**
    ///
    /// LAW 2: the assertion is on the rendered frame, not on `assert!(painted())`. A
    /// control frame with nothing painted gives the background the trail is measured
    /// against, so the counts below are the trail's ink and not the pane's.
    ///
    /// RED: paint one plain `Shape::line` instead of the ladder and the halo cross-
    /// section collapses to the core width, failing the reach assertion.
    #[test]
    fn the_glow_trail_lays_a_wide_halo_and_a_hot_core() {
        // THE GPU TURN — `egui_kittest::Harness::render()` brings up its OWN wgpu
        // device, and this file holds the only two `.render()` calls in any facett
        // `--lib` test. Two of them on parallel `#[test]` threads is the shape
        // `facett_core::render::gputurn`'s table records for 2026-08-27: MEASURED here
        // 2026-09-03, `facett_graph3d neon:: --test-threads=2` took a SIGSEGV in
        // **17 of 30** runs and 0 of 15 at `--test-threads=1` — which is why
        // `cargo test --workspace --lib` died in this crate and passed serialised.
        // The turn, not a second mutex (LAW 5): the same one facett-map's kittest
        // files and facett-core's eight probes take.
        let _gpu = facett_core::render::gputurn::hold();
        const W: usize = 240;
        const H: usize = 120;
        let render = |ladder: bool, plain: bool| -> Vec<u8> {
            let pts = vec![pos2(20.0, 60.0), pos2(120.0, 60.0), pos2(220.0, 60.0)];
            let mut h = egui_kittest::Harness::builder()
                .with_size(egui::vec2(W as f32, H as f32))
                .build_ui(move |ui| {
                    let painter = ui.painter();
                    painter.rect_filled(
                        egui::Rect::from_min_size(
                            pos2(0.0, 0.0),
                            egui::vec2(W as f32, H as f32),
                        ),
                        0.0,
                        Color32::BLACK,
                    );
                    if ladder {
                        paint_glow_trail(painter, &pts, Color32::from_rgb(0, 242, 255), 2.0, None);
                    } else if plain {
                        painter.add(egui::Shape::line(
                            pts.clone(),
                            Stroke::new(2.0, Color32::from_rgb(0, 242, 255)),
                        ));
                    }
                });
            h.run_steps(3);
            h.render().expect("offscreen render").into_raw()
        };
        let bare = render(false, false);
        let plain = render(false, true);
        let glow = render(true, false);

        // The lit cross-section down the middle column of the trail: how many rows the
        // stroke touches at x = 120 (the trail runs horizontally through y = 60).
        let column_run = |img: &Vec<u8>| -> usize {
            (0..H)
                .filter(|&y| {
                    let i = (y * W + 120) * 4;
                    img[i..i + 3] != bare[i..i + 3]
                })
                .count()
        };
        let (plain_run, glow_run) = (column_run(&plain), column_run(&glow));
        assert!(plain_run >= 2, "the plain stroke is on the pane: {plain_run} px");
        assert!(
            glow_run >= plain_run * 4,
            "the ladder's halo reaches far past a plain stroke: {glow_run} px vs \
             {plain_run} px",
        );

        // The CORE burns brighter than the plain stroke at the same width — the
        // `white` blend, seen in the frame.
        let brightest = |img: &Vec<u8>| -> u32 {
            (0..W * H)
                .map(|i| {
                    u32::from(img[i * 4]) + u32::from(img[i * 4 + 1]) + u32::from(img[i * 4 + 2])
                })
                .max()
                .unwrap_or(0)
        };
        assert!(
            brightest(&glow) > brightest(&plain),
            "the core is hotter than the flat hue: {} vs {}",
            brightest(&glow),
            brightest(&plain),
        );
        println!(
            "GLOW TRAIL: cross-section {plain_run} px (plain) -> {glow_run} px (ladder) \
             · peak {} -> {}",
            brightest(&plain),
            brightest(&glow),
        );
    }

    /// A path with fewer than two points paints nothing — the honest empty. Measured on
    /// the frame: it is byte-identical to one where nothing was asked for at all.
    #[test]
    fn a_degenerate_trail_paints_nothing() {
        // THE GPU TURN, same as above: this body brings up two more kittest devices,
        // and it is the sibling thread that the halo test collided with.
        let _gpu = facett_core::render::gputurn::hold();
        let frame = |draw: bool| -> Vec<u8> {
            let mut h = egui_kittest::Harness::builder()
                .with_size(egui::vec2(64.0, 64.0))
                .build_ui(move |ui| {
                    if draw {
                        paint_glow_trail(
                            ui.painter(),
                            &[pos2(10.0, 10.0)],
                            Color32::RED,
                            2.0,
                            Some(0.3),
                        );
                    }
                });
            h.run_steps(2);
            h.render().expect("render").into_raw()
        };
        assert_eq!(frame(true), frame(false), "one point is not a trail");
    }
}