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