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/codevault.rs

//! **The Wreck-It-Ralph "Code Vault"** — the warehouse rendered as a dark 3D vault
//! of **labeled boxes** (code units: crates/modules) wired by **connectors**
//! (dependency edges), fly-navigable. Ref: the *Game Code Room* in Wreck-It-Ralph
//! (2012), where code is stored as boxes/columns tied together in the dark and a
//! **glitch** (Vanellope) reads as a red, corrupted, flickering object.
//!
//! Dual use (warehouse-navigator-design.md §4):
//! - **glitches** — a code unit whose [`state`](CodeNode::state) is [`NodeState::Glitch`]
//!   (a bug / failing test / reported problem) renders as a **red-flickering,
//!   position-jittered** box + connectors; a [`NodeState::Changed`] unit glows amber.
//! - **navigation** — fly between labeled boxes (orbit/spin camera, click-to-focus).
//!
//! ## Reuse, not fork (L5)
//! The vault is a **skin over [`Graph3D`]**: the camera (orbit `yaw` + auto-`spin` +
//! the injected FC-7 clock), the [`Layout3D`] node placement, the projection, and the
//! click-selection all come from the embedded [`Graph3D`] — the [`Elm`] `Model` is its
//! [`Graph3DView`](crate::Graph3DView), reused verbatim. The Code Vault adds only what
//! is genuinely new: per-node [`NodeState`], the [`Material`] (the **emissive +
//! transparent** glowing-box variant the owner asked for), and the box/glitch **paint**.
//! Billboard labels reuse [`facett_core::labels3d`]; glow/flicker reuse
//! [`facett_core::render::decor`]; the GPU **bloom** lane reuses [`crate::graph_gpu`].
//!
//! ## What it consumes from nornir
//! [`CodeGraph`] IS the nornir *warehouse-model* shape — `nodes` = code units (label +
//! colour + `ok`/`changed`/`glitch` state), `edges` = dependency pairs. It is serde
//! round-trippable, so once the sibling nornir agent emits the warehouse model as JSON
//! it drives the vault directly ([`CodeGraph::from_json`]); until then a fixture (or any
//! `state_json`) drives it, so this crate compiles + tests **standalone**.

use std::cell::RefCell;

use egui::{Color32, Pos2, Sense, Shape, Stroke, Vec2, vec2};
use serde::{Deserialize, Serialize};

use facett_core::nav::DrillNav;
use facett_core::render::decor;
use facett_core::{FacetCaps, theme};

use crate::neon;
use crate::{Graph3D, Graph3DView, Layout3D, Msg as CamMsg};

/// Cycles-per-second the injected clock drives the glitch flicker at — fast enough
/// that a glitch box reads as *corrupted*, not *breathing*.
const FLICKER_RATE: f32 = 2.3;

/// The alert-red a glitch box/connector is tinted toward (the Vanellope glitch hue).
const GLITCH_RED: Color32 = Color32::from_rgb(255, 45, 55);

/// The amber a *changed* (recently-touched) box glows.
const CHANGED_AMBER: Color32 = Color32::from_rgb(255, 176, 32);

/// **The health of a code unit** in the vault — the `node.state` the design calls for.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NodeState {
    /// Healthy — renders in its own colour.
    #[default]
    Ok,
    /// Recently changed (a commit/diff anchor) — glows amber, steady.
    Changed,
    /// A bug / failing test / reported problem — **red, flickering, jittered**.
    Glitch,
}

impl NodeState {
    /// The stable string used in `state_json` / the nornir warehouse-model JSON.
    pub fn label(self) -> &'static str {
        match self {
            NodeState::Ok => "ok",
            NodeState::Changed => "changed",
            NodeState::Glitch => "glitch",
        }
    }
}

/// **The box material.** [`EmissiveTransparent`](Material::EmissiveTransparent) is the
/// "shiny glowing transparent code boxes" variant — translucent fills (so wiring shows
/// through) plus a glow orb behind each box (emissive; on the GPU lane this feeds the
/// existing [bloom](crate::graph_gpu)). [`Solid`](Material::Solid) is the flat,
/// opaque box (the calm/accessible default).
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Material {
    /// Flat opaque boxes, no glow.
    Solid,
    /// Glowing translucent boxes — bloom-fed emissive + an alpha (OIT-lite) fill.
    #[default]
    EmissiveTransparent,
}

impl Material {
    pub fn label(self) -> &'static str {
        match self {
            Material::Solid => "solid",
            Material::EmissiveTransparent => "emissive_transparent",
        }
    }
    /// The box fill alpha for this material (transparent < 1.0 lets connectors show
    /// through — the cheap OIT the design asks for).
    fn fill_alpha(self) -> f32 {
        match self {
            Material::Solid => 1.0,
            Material::EmissiveTransparent => 0.62,
        }
    }
    fn emissive(self) -> bool {
        matches!(self, Material::EmissiveTransparent)
    }
}

/// One **code unit** in the warehouse model — a crate or module: a `label`, a `color`
/// (per-repo categorical, supplied by the producer), and a health `state`.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CodeNode {
    pub label: String,
    /// Categorical colour (RGB). Serialized as `[r, g, b]` so the nornir warehouse
    /// model can hand plain arrays.
    #[serde(with = "rgb_serde")]
    pub color: Color32,
    #[serde(default)]
    pub state: NodeState,
}

impl CodeNode {
    pub fn new(label: impl Into<String>, color: Color32, state: NodeState) -> Self {
        Self { label: label.into(), color, state }
    }
}

/// **The nornir warehouse-model input** — the graph the Code Vault renders. `nodes` =
/// code units, `edges` = dependency pairs (`(from, to)` indices into `nodes`).
/// Serde round-trippable so it is populated straight from the sibling nornir agent's
/// `state_json` (or a fixture) with no glue.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct CodeGraph {
    pub nodes: Vec<CodeNode>,
    pub edges: Vec<(usize, usize)>,
}

impl CodeGraph {
    pub fn new(nodes: Vec<CodeNode>, edges: Vec<(usize, usize)>) -> Self {
        Self { nodes, edges }
    }

    /// Parse a nornir warehouse-model `state_json` value into a [`CodeGraph`]. The
    /// exact serde shape (`{ "nodes": [{label,color,state}], "edges": [[a,b]] }`), so
    /// the sibling agent's emitter and a test fixture share ONE format.
    pub fn from_json(v: &serde_json::Value) -> Result<Self, serde_json::Error> {
        serde_json::from_value(v.clone())
    }

    /// The number of code units whose state is [`NodeState::Glitch`] — the "how many
    /// bugs are lit" readout.
    pub fn glitch_count(&self) -> usize {
        self.nodes.iter().filter(|n| n.state == NodeState::Glitch).count()
    }
}

/// `Color32` ⇄ `[u8; 3]` for serde (the warehouse model carries plain RGB arrays).
mod rgb_serde {
    use egui::Color32;
    use serde::{Deserialize, Deserializer, Serializer};

    pub fn serialize<S: Serializer>(c: &Color32, s: S) -> Result<S::Ok, S::Error> {
        [c.r(), c.g(), c.b()].serialize(s)
    }
    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Color32, D::Error> {
        let [r, g, b] = <[u8; 3]>::deserialize(d)?;
        Ok(Color32::from_rgb(r, g, b))
    }
    use serde::Serialize;
}

/// A robot-/CLI-addressable message (FC-2). Camera/layout/selection reuse the embedded
/// [`Graph3D`]'s [`Msg`](crate::Msg) verbatim (wrapped in [`Camera`](CodeVaultMsg::Camera));
/// the only net-new input is [`SetMaterial`](CodeVaultMsg::SetMaterial).
#[derive(Clone, Debug, PartialEq)]
pub enum CodeVaultMsg {
    /// A camera/layout/selection/clock message routed to the embedded [`Graph3D`].
    Camera(CamMsg),
    /// Switch the box [`Material`] (solid vs the emissive-transparent glow variant).
    SetMaterial(Material),
    /// **Navigate back** up one drill level (the right-click gesture) — pops the nav
    /// history, returning to the parent level. No-op at the root.
    Back,
    /// **Drill down** into box `i` — descend a level and unfold its children in place.
    Drill(usize),
    /// **Unfold** box `i` in place — show its dependency children WITHOUT closing it
    /// or any ancestor (progressive class-diagram expand).
    Unfold(usize),
    /// **Fold** box `i` — hide its children again (the box itself stays visible).
    Fold(usize),
}

/// Side work as data (FC-8) — the vault does no I/O, so this is uninhabited.
#[derive(Clone, Debug, PartialEq)]
pub enum CodeVaultEffect {}

/// **The Code Vault viewer.** A box/glitch skin over an embedded [`Graph3D`] (camera +
/// layout + projection + selection, reused — L5). Adds per-node [`NodeState`] + the
/// [`Material`] + the box/glitch paint.
pub struct CodeVault {
    pub title: String,
    /// The reused camera + geometry + selection engine. Its [`Graph3DView`] IS this
    /// component's [`Elm`] `Model`.
    graph: Graph3D,
    /// Per-node health, parallel to `graph.nodes` — a render input (like `graph.nodes`
    /// itself, held OUTSIDE the Model).
    states: Vec<NodeState>,
    /// The box material — a render input; observable via `state_json`, mutated only
    /// through [`CodeVaultMsg::SetMaterial`] (the single FC-2 path).
    material: Material,
    /// The shared drill-down navigator (levels + history/back + in-place unfold),
    /// reused from [`facett_core::nav`] (L5). Starts fully unfolded so the vault shows
    /// the whole warehouse; folding/back are the opt-in restrictions.
    nav: DrillNav,
    /// Ephemeral billboard-label anchor cache (outside the Model), like [`Graph3D`]'s.
    label_anchors: RefCell<Vec<Option<Pos2>>>,
}

impl CodeVault {
    /// Build a vault from a [`CodeGraph`] warehouse model, laid out by `layout`
    /// (`Force` reads most like the Wreck-It-Ralph wiring).
    pub fn new(graph: CodeGraph, layout: Layout3D) -> Self {
        let states: Vec<NodeState> = graph.nodes.iter().map(|n| n.state).collect();
        let labels: Vec<String> = graph.nodes.iter().map(|n| n.label.clone()).collect();
        let nodes: Vec<(String, Color32)> = graph.nodes.into_iter().map(|n| (n.label, n.color)).collect();
        let n = nodes.len();
        let g = Graph3D::new(nodes, graph.edges, layout).with_title("codevault");
        // Start fully unfolded so the whole warehouse is visible (no regression);
        // folding / back are the opt-in restrictions the navigator drives.
        let mut nav = DrillNav::warehouse();
        nav.unfold_all(labels.iter().map(|s| s.as_str()));
        Self {
            title: "codevault".into(),
            graph: g,
            states,
            material: Material::default(),
            nav,
            label_anchors: RefCell::new(vec![None; n]),
        }
    }

    /// The label of box `i` (its stable id in the drill navigator), if in range.
    fn label_of(&self, i: usize) -> Option<String> {
        self.graph.nodes.get(i).map(|n| n.label.clone())
    }

    /// The drill-navigator roots: code units with **no incoming dependency** (the
    /// top of the dep DAG — the level-0 anchors the visible set expands from).
    fn nav_roots(&self) -> Vec<String> {
        let mut incoming = vec![false; self.graph.nodes.len()];
        for &(_, b) in &self.graph.edges {
            if b < incoming.len() {
                incoming[b] = true;
            }
        }
        self.graph.nodes.iter().enumerate().filter(|(i, _)| !incoming[*i]).map(|(_, n)| n.label.clone()).collect()
    }

    /// The dependency children of code unit `label` (edge targets) — the child lookup
    /// the in-place unfold expands through.
    fn children_of(&self, label: &str) -> Vec<String> {
        let Some(idx) = self.graph.nodes.iter().position(|n| n.label == label) else {
            return Vec::new();
        };
        self.graph
            .edges
            .iter()
            .filter(|&&(a, _)| a == idx)
            .filter_map(|&(_, b)| self.graph.nodes.get(b).map(|n| n.label.clone()))
            .collect()
    }

    /// The set of box labels **visible** under the current progressive unfold — the
    /// roots plus every expanded node's children (ancestors always retained). Default
    /// (fully unfolded) = every box.
    pub fn visible_labels(&self) -> std::collections::BTreeSet<String> {
        self.nav.visible(self.nav_roots(), |l| self.children_of(l))
    }

    /// Read-only access to the drill navigator (levels / history / expanded).
    pub fn nav(&self) -> &DrillNav {
        &self.nav
    }

    pub fn with_title(mut self, t: impl Into<String>) -> Self {
        self.title = t.into();
        self
    }

    /// Builder form of the material switch.
    pub fn with_material(mut self, m: Material) -> Self {
        self.material = m;
        self
    }

    /// Pin the injected animation clock (seconds; FC-7) so the glitch flicker is a
    /// **pure function of the clock** — identical state + clock ⇒ identical frame.
    pub fn with_clock(mut self, seconds: f64) -> Self {
        let _ = self.graph.update(CamMsg::SetClock(seconds));
        self
    }

    /// Read the reused camera/selection Model.
    pub fn state(&self) -> &Graph3DView {
        self.graph.state()
    }

    /// **Overwrite the box positions** (the shared-placement seam) — place the boxes on
    /// explicit coordinates (normalised ~`[-1, 1]`) instead of the `Force`/`Sphere`
    /// layout. A host that lays boxes on its OWN planes — e.g. facett-releaseview's
    /// convergence z-planes (LOCAL front · SPARRING mid · CRATES_IO back) that snap
    /// together as a release converges — drives this after [`new`](Self::new). Positions
    /// are consumed in **node-index order**; extra entries are ignored and short slices
    /// leave the trailing boxes at their laid-out spot. This is the thin index-order
    /// setter over the reused [`Graph3D`] cloud (the `Node3::pos` seam is already `pub`;
    /// L5 — no fork of the renderer).
    pub fn set_positions(&mut self, positions: &[[f32; 3]]) {
        for (nd, p) in self.graph.nodes.iter_mut().zip(positions.iter()) {
            nd.pos = *p;
        }
        // This writes through the PUBLIC `nodes` seam, which `Graph3D::generation`
        // cannot observe — so say so, or a GPU host holding uploaded buffers keeps
        // drawing the cloud from before this call.
        self.graph.touch();
    }

    /// Builder form of [`set_positions`](Self::set_positions).
    pub fn with_positions(mut self, positions: &[[f32; 3]]) -> Self {
        self.set_positions(positions);
        self
    }

    /// The active box material.
    pub fn material(&self) -> Material {
        self.material
    }

    /// Indices of the glitching code units (state == [`NodeState::Glitch`]).
    pub fn glitch_nodes(&self) -> Vec<usize> {
        (0..self.states.len()).filter(|&i| self.states[i] == NodeState::Glitch).collect()
    }

    /// **The glitch flicker envelope** at code-unit `i` for animation `phase` (cycles).
    /// Non-glitch units are steady (`1.0`); a glitch unit gets an *irregular corrupted
    /// strobe* in `[0.15, 1.0]` that changes with the clock — the observable a robot
    /// asserts is time-varying (RED-when-broken: a static value = the glitch died).
    /// Pure, so a pinned clock makes it byte-stable.
    pub fn flicker(&self, i: usize, phase: f32) -> f32 {
        if self.states.get(i).copied() != Some(NodeState::Glitch) {
            return 1.0;
        }
        // Two detuned envelopes so the flash reads as corrupted, not a clean pulse.
        let a = decor::strobe(phase * 1.7 + i as f32 * 0.37, 0.55);
        let b = decor::pulse((phase * 1.13 + i as f32 * 0.71).rem_euclid(1.0));
        (0.15 + 0.85 * (0.55 * a + 0.45 * b)).clamp(0.15, 1.0)
    }

    /// The deterministic flicker phase (from the injected clock; `0` when following
    /// ambient time) used by `state_json`, so the reported flicker is reproducible.
    fn state_phase(&self) -> f32 {
        self.graph.state().clock_s.unwrap_or(0.0) as f32 * FLICKER_RATE
    }

    /// The **effective render colour + fill alpha** of box `i` at `phase`: glitch →
    /// red, flicker-modulated; changed → amber; else the unit's own colour. `sel_dim`
    /// dims non-selected boxes when something is focused.
    fn box_look(&self, i: usize, base: Color32, phase: f32, sel_dim: f32) -> (Color32, f32) {
        let a = self.material.fill_alpha() * sel_dim;
        match self.states.get(i).copied().unwrap_or_default() {
            NodeState::Ok => (base, a),
            NodeState::Changed => (blend(base, CHANGED_AMBER, 0.6), a),
            NodeState::Glitch => {
                let f = self.flicker(i, phase);
                // Corrupted: tint hard toward red, and drive the fill alpha with the
                // flicker so the box visibly blinks out and back.
                (blend(base, GLITCH_RED, 0.85), (a * (0.35 + 0.65 * f)).clamp(0.0, 1.0))
            }
        }
    }

    /// **FC-2 / FC-8** — the single mutation path. Camera messages route to the reused
    /// [`Graph3D`]; [`SetMaterial`](CodeVaultMsg::SetMaterial) flips the box material.
    pub fn update(&mut self, msg: CodeVaultMsg) -> Vec<CodeVaultEffect> {
        match msg {
            CodeVaultMsg::Camera(m) => {
                let _ = self.graph.update(m);
            }
            CodeVaultMsg::SetMaterial(m) => self.material = m,
            CodeVaultMsg::Back => {
                self.nav.back();
            }
            CodeVaultMsg::Drill(i) => {
                if let Some(l) = self.label_of(i) {
                    self.nav.drill(l);
                }
            }
            CodeVaultMsg::Unfold(i) => {
                if let Some(l) = self.label_of(i) {
                    self.nav.unfold(l);
                }
            }
            CodeVaultMsg::Fold(i) => {
                if let Some(l) = self.label_of(i) {
                    self.nav.fold(&l);
                }
            }
        }
        Vec::new()
    }

    /// **FC-9 render** — a **pure** paint that projects the code units, draws each as a
    /// depth-shaded labeled box wired by connectors, red-flickers the glitches, and
    /// *returns* the camera [`CodeVaultMsg`]s the interactions produced (orbit drag,
    /// auto-spin tick, click-to-focus). It never mutates the Model.
    pub fn view(&self, ui: &mut egui::Ui) -> Vec<CodeVaultMsg> {
        let mut msgs: Vec<CodeVaultMsg> = Vec::new();
        let th = theme(ui);
        let cam = self.graph.state();

        // Auto-spin tick (emit through the reused camera, exactly like Graph3D).
        if cam.spin && cam.clock_s.is_none() {
            let dt = ui.input(|i| i.stable_dt).min(0.1);
            msgs.push(CodeVaultMsg::Camera(CamMsg::AdvanceSpin(dt)));
            ui.ctx().request_repaint();
        }
        let render_yaw = self.graph.render_yaw();

        let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::click_and_drag());
        if resp.dragged() {
            let dx = resp.drag_delta().x;
            if dx != 0.0 {
                msgs.push(CodeVaultMsg::Camera(CamMsg::DragYaw(dx)));
            }
        }
        resp.widget_info(|| {
            facett_core::a11y::Semantics::image(format!(
                "Code Vault — {} boxes · {} glitches · yaw {:.0}°",
                self.states.len(),
                self.glitch_nodes().len(),
                render_yaw.to_degrees().rem_euclid(360.0),
            ))
            .value(render_yaw as f64)
            .widget_info()
        });

        let painter = ui.painter_at(rect);
        // The dark vault floor.
        painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);
        if self.states.is_empty() {
            painter.text(rect.center(), egui::Align2::CENTER_CENTER, "empty vault", egui::FontId::proportional(13.0), th.text_dim);
            #[cfg(feature = "testmatrix")]
            facett_core::testmatrix::emit("facett-graph3d::CodeVault::ui", "ui_render", true, "boxes=0 drew=empty_hint");
            return msgs;
        }

        let center = rect.center();
        let scale = rect.size().min_elem() * 0.40;
        let proj = self.graph.project_all(center, scale, render_yaw);

        // Live flicker follows ambient time; a pinned clock is a pure function of it.
        let phase = cam.clock_s.unwrap_or_else(|| ui.input(|i| i.time)) as f32 * FLICKER_RATE;
        // Any glitch present ⇒ keep repainting so it actually flickers live.
        if cam.clock_s.is_none() && !self.glitch_nodes().is_empty() {
            ui.ctx().request_repaint();
        }

        // Right-click anywhere = navigate BACK up one drill level (the nav-history pop).
        if resp.secondary_clicked() {
            msgs.push(CodeVaultMsg::Back);
        }
        // Left-click → focus the nearest box (reuse the graph's selection); empty → clear.
        if resp.clicked() && let Some(pp) = resp.interact_pointer_pos() {
            let hit = (0..proj.len())
                .map(|i| (i, proj[i].0.distance_sq(pp)))
                .filter(|&(_, d)| d < 28.0_f32.powi(2))
                .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
                .map(|(i, _)| i);
            msgs.push(CodeVaultMsg::Camera(match hit {
                Some(i) => CamMsg::SelectNode(i),
                None => CamMsg::ClearSelection,
            }));
        }

        let sel = cam.selected.filter(|&s| s < self.states.len());
        // The progressive-unfold visible set: only these boxes (+ their wiring) paint,
        // so folding a node hides its children while ancestors stay (default = all).
        let visible = self.visible_labels();
        let shown = |i: usize| visible.contains(&self.graph.nodes[i].label);

        // ── connectors (dep edges) — drawn first, under the boxes ──────────────────
        for &(a, b) in &self.graph.edges {
            if a < proj.len() && b < proj.len() && shown(a) && shown(b) {
                let depth = (proj[a].1 + proj[b].1) * 0.5;
                let glitchy = self.states[a] == NodeState::Glitch || self.states[b] == NodeState::Glitch;
                let (col, w) = if glitchy {
                    // A glitching unit corrupts its wiring too — red + flickering.
                    let f = self.flicker(a, phase).min(self.flicker(b, phase));
                    (blend(th.edge, GLITCH_RED, 0.8).gamma_multiply(0.35 + 0.65 * f), 1.6_f32)
                } else {
                    let alpha = ((110.0 + depth * 90.0).clamp(20.0, 200.0)) / 255.0;
                    (th.edge.gamma_multiply(alpha), 1.0_f32)
                };
                painter.line_segment([proj[a].0, proj[b].0], Stroke::new(w, col));
            }
        }

        // ── boxes, back-to-front (painter's algorithm) ─────────────────────────────
        let mut order: Vec<usize> = (0..proj.len()).collect();
        order.sort_by(|&i, &j| proj[i].1.partial_cmp(&proj[j].1).unwrap_or(std::cmp::Ordering::Equal));
        for i in order {
            if !shown(i) {
                continue;
            }
            let (p, z) = proj[i];
            // Box half-size grows with nearness (depth cue); glitch jitter corrupts pos.
            let half = 6.0 + (z + 1.0) * 4.0;
            let sel_dim = match sel {
                Some(s) if s != i => 0.35,
                _ => 1.0,
            };
            let (col, alpha) = self.box_look(i, self.graph.nodes[i].color, phase, sel_dim);
            // Glitch position jitter (the box tears): a hashed offset scaled by flicker.
            let jitter = if self.states[i] == NodeState::Glitch {
                let f = self.flicker(i, phase);
                let jx = ((i as f32 * 12.9898 + phase * 78.233).sin() * 43_758.547).fract() - 0.5;
                let jy = ((i as f32 * 39.3468 + phase * 11.135).sin() * 24_634.635).fract() - 0.5;
                vec2(jx, jy) * (1.0 - f) * half * 0.9
            } else {
                Vec2::ZERO
            };
            let c = p + jitter;
            // Emissive: a glow orb behind the box (bloom-fed on the GPU lane).
            if self.material.emissive() && alpha > 0.05 {
                let orb = if self.states[i] == NodeState::Glitch { GLITCH_RED } else { col };
                neon::paint_node_orb(&painter, c, half * 1.15, orb, 0.5, alpha);
            }
            paint_box(&painter, c, half, col, alpha);
        }

        // ── billboard labels (reuse facett_core::labels3d) ─────────────────────────
        if cam.show_labels {
            let mut anchors = self.label_anchors.borrow_mut();
            if anchors.len() != self.states.len() {
                *anchors = vec![None; self.states.len()];
            }
            let font = egui::FontId::proportional(11.0);
            let deterministic = cam.clock_s.is_some();
            for i in 0..proj.len() {
                if !shown(i) {
                    continue;
                }
                let (p, z) = proj[i];
                let half = 6.0 + (z + 1.0) * 4.0;
                let raw = p + vec2(0.0, -half - 3.0);
                let prior = if deterministic { None } else { anchors[i] };
                let placed = facett_core::labels3d::billboard_placement(raw, prior, 0.5, 0.5);
                anchors[i] = Some(placed);
                let bright = (0.5 + (z + 1.0) * 0.25).clamp(0.4, 1.0);
                let tint = if self.states[i] == NodeState::Glitch { blend(th.text, GLITCH_RED, 0.7) } else { th.text };
                painter.text(placed, egui::Align2::CENTER_BOTTOM, &self.graph.nodes[i].label, font.clone(), tint.gamma_multiply(bright));
            }
        }

        #[cfg(feature = "testmatrix")]
        {
            let finite = proj.iter().all(|(p, z)| p.x.is_finite() && p.y.is_finite() && z.is_finite());
            facett_core::testmatrix::emit(
                "facett-graph3d::CodeVault::ui",
                "ui_render",
                !self.states.is_empty() && finite,
                &format!("boxes={} edges={} glitches={} material={} projection_finite={finite}", self.states.len(), self.graph.edges.len(), self.glitch_nodes().len(), self.material.label()),
            );
        }
        msgs
    }

    /// **Build the GPU bloom-lane scene** (feature `wgpu`) — reuse [`crate::graph_gpu`]
    /// (additive HDR + bright-pass + gaussian blur + composite) with box centres as
    /// emissive node discs: glitch units carry **red** radiance flicker-modulated at
    /// `phase`, so the same bloom that glows the graph makes a bug throb red. Pure
    /// projection (no egui), so it is unit-testable headless.
    #[cfg(feature = "wgpu")]
    pub fn gpu_scene(&self, w: f32, h: f32, phase: f32) -> crate::graph_gpu::GraphScene {
        let center = Pos2::new(w * 0.5, h * 0.5);
        let scale = w.min(h) * 0.40;
        let proj = self.graph.project_all(center, scale, self.graph.render_yaw());
        let emissive = if self.material.emissive() { 3.0 } else { 1.2 };
        let radiance = |c: Color32, depth: f32, mul: f32| -> [f32; 4] {
            let lin = |b: u8| {
                let s = b as f32 / 255.0;
                s * s
            };
            let dbright = (0.55 + (depth + 1.0) * 0.35).clamp(0.4, 1.3);
            let k = emissive * dbright * mul;
            [lin(c.r()) * k, lin(c.g()) * k, lin(c.b()) * k, 1.0]
        };
        let nodes = proj
            .iter()
            .enumerate()
            .map(|(i, &(p, z))| {
                let (col, _) = self.box_look(i, self.graph.nodes[i].color, phase, 1.0);
                // Glitches pump their radiance with the flicker so bloom throbs.
                let mul = if self.states[i] == NodeState::Glitch { 0.5 + 1.5 * self.flicker(i, phase) } else { 1.0 };
                crate::graph_gpu::NodeInstance { center: [p.x, p.y], radius: 4.0 + (z + 1.0) * 3.0, _pad: 0.0, color: radiance(col, z, mul) }
            })
            .collect();
        let edges = self
            .graph
            .edges
            .iter()
            .filter(|&&(a, b)| a < proj.len() && b < proj.len())
            .map(|&(a, b)| {
                let depth = (proj[a].1 + proj[b].1) * 0.5;
                crate::graph_gpu::EdgeInstance { a: [proj[a].0.x, proj[a].0.y], b: [proj[b].0.x, proj[b].0.y], width: 1.5, _pad: 0.0, color: radiance(self.graph.nodes[a].color, depth, 0.6) }
            })
            .collect();
        crate::graph_gpu::GraphScene { background: [0.01, 0.01, 0.02, 1.0], nodes, edges }
    }
}

/// Paint one pseudo-3D **labeled box** (a cuboid) at screen `c` with half-extent
/// `half`, filled with `col` at `alpha` — a front face plus a lighter top face and a
/// darker right face, so it reads as a solid box in the vault (the Wreck-It-Ralph
/// box). Uses only the CPU painter (wasm-clean).
fn paint_box(painter: &egui::Painter, c: Pos2, half: f32, col: Color32, alpha: f32) {
    if alpha <= 0.02 {
        return;
    }
    let d = vec2(half * 0.45, -half * 0.45); // the depth (extrusion) offset
    let shade = |k: f32| col.gamma_multiply((k * alpha).clamp(0.0, 1.0));
    // Front face corners.
    let ftl = c + vec2(-half, -half);
    let ftr = c + vec2(half, -half);
    let fbr = c + vec2(half, half);
    let fbl = c + vec2(-half, half);
    // Top face (front-top edge extruded back by d).
    painter.add(Shape::convex_polygon(vec![ftl, ftr, ftr + d, ftl + d], shade(0.85), Stroke::NONE));
    // Right face.
    painter.add(Shape::convex_polygon(vec![ftr, fbr, fbr + d, ftr + d], shade(0.6), Stroke::NONE));
    // Front face (brightest) with a thin edge so boxes read crisply.
    let edge = Color32::from_black_alpha((160.0 * alpha) as u8);
    painter.add(Shape::convex_polygon(vec![ftl, ftr, fbr, fbl], shade(1.0), Stroke::new(1.0_f32, edge)));
}

/// Straight linear-ish blend `a → b` by `t ∈ [0,1]`, preserving `a`'s alpha.
fn blend(a: Color32, b: Color32, t: f32) -> Color32 {
    let l = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t).round().clamp(0.0, 255.0) as u8;
    Color32::from_rgba_unmultiplied(l(a.r(), b.r()), l(a.g(), b.g()), l(a.b(), b.b()), a.a())
}

// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split (Model reused from Graph3D) ──
impl facett_core::Elm for CodeVault {
    type Model = Graph3DView;
    type Msg = CodeVaultMsg;
    type Effect = CodeVaultEffect;

    fn title(&self) -> &str {
        &self.title
    }
    fn state(&self) -> &Graph3DView {
        self.graph.state()
    }
    fn update(&mut self, msg: CodeVaultMsg) -> Vec<CodeVaultEffect> {
        CodeVault::update(self, msg)
    }
    fn view(&self, ui: &mut egui::Ui) -> Vec<CodeVaultMsg> {
        CodeVault::view(self, ui)
    }
}

// The vault publishes a RICHER `state_json` than the raw camera Model — the glitch /
// material / geometry observables the demo matrix + a robot driver read — so form 3
// (`custom_state_json`) suppresses the default and supplies this one.
facett_core::impl_facet_via_elm!(CodeVault, custom_state_json, {
    fn state_json(&self) -> serde_json::Value {
        let cam = self.graph.state();
        let phase = self.state_phase();
        let glitch = self.glitch_nodes();
        let changed: Vec<usize> = (0..self.states.len()).filter(|&i| self.states[i] == NodeState::Changed).collect();
        // Per-node flicker at the (deterministic) clock phase — the observable a robot
        // asserts is time-varying for a glitch, constant for the rest.
        let flicker: Vec<f32> = (0..self.states.len()).map(|i| self.flicker(i, phase)).collect();
        serde_json::json!({
            "boxes": self.states.len(),
            "edges": self.graph.edges.len(),
            "layout": cam.layout.label(),
            "yaw": cam.yaw,
            "spin": cam.spin,
            "clock_s": cam.clock_s,
            "show_labels": cam.show_labels,
            "selected_node": cam.selected.filter(|&s| s < self.states.len()),
            "material": self.material.label(),
            "emissive": self.material.emissive(),
            "fill_alpha": self.material.fill_alpha(),
            "glitch_nodes": glitch,
            "glitch_count": glitch.len(),
            "changed_nodes": changed,
            "changed_count": changed.len(),
            "flicker_phase": phase,
            "flicker": flicker,
            // The shared drill navigator: levels / current level / history / expanded.
            "nav": self.nav.state_json(),
            // How many boxes are currently shown under the progressive unfold.
            "visible_count": self.visible_labels().len(),
        })
    }
    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.themeable().resizable().selectable()
    }
    fn kind(&self) -> &'static str {
        "codevault"
    }
});

#[cfg(test)]
mod tests {
    use super::*;
    use egui::pos2;
    use facett_core::Facet;
    use facett_core::harness;

    /// A small warehouse fixture: 5 code units in a chain, unit 2 glitching, unit 4
    /// changed. Mirrors the nornir warehouse-model shape.
    fn fixture() -> CodeGraph {
        let c = |r, g, b| Color32::from_rgb(r, g, b);
        CodeGraph::new(
            vec![
                CodeNode::new("facett-core", c(80, 180, 250), NodeState::Ok),
                CodeNode::new("facett-graph3d", c(120, 200, 120), NodeState::Ok),
                CodeNode::new("nornir-warehouse", c(240, 120, 90), NodeState::Glitch),
                CodeNode::new("holger-repo", c(200, 160, 240), NodeState::Ok),
                CodeNode::new("facett-demo", c(240, 200, 80), NodeState::Changed),
            ],
            vec![(1, 0), (2, 1), (2, 0), (3, 0), (4, 1)],
        )
    }

    #[test]
    fn codegraph_round_trips_through_json_and_counts_glitches() {
        let cg = fixture();
        assert_eq!(cg.glitch_count(), 1);
        // The nornir warehouse-model seam: serialize → parse back → identical shape.
        let j = serde_json::to_value(&cg).unwrap();
        assert_eq!(j["nodes"][2]["state"], "glitch");
        assert_eq!(j["nodes"][2]["color"], serde_json::json!([240, 120, 90]));
        let back = CodeGraph::from_json(&j).unwrap();
        assert_eq!(back.nodes.len(), 5);
        assert_eq!(back.nodes[2].state, NodeState::Glitch);
        assert_eq!(back.edges, cg.edges);
    }

    #[test]
    fn state_json_reports_glitch_material_and_geometry() {
        let v = CodeVault::new(fixture(), Layout3D::Force);
        let j = v.state_json();
        assert_eq!(j["boxes"], 5);
        assert_eq!(j["edges"], 5);
        assert_eq!(j["glitch_count"], 1);
        assert_eq!(j["glitch_nodes"], serde_json::json!([2]));
        assert_eq!(j["changed_nodes"], serde_json::json!([4]));
        // Default material is the shiny glowing transparent variant.
        assert_eq!(j["material"], "emissive_transparent");
        assert_eq!(j["emissive"], true);
        assert!(j["fill_alpha"].as_f64().unwrap() < 1.0, "transparent boxes let wiring show through");
    }

    // ── RED-when-broken: the glitch actually flickers ──────────────────────────────
    #[test]
    fn glitch_node_flickers_over_time_but_healthy_nodes_are_steady() {
        // Two pinned clocks a beat apart. The glitch unit's flicker MUST differ between
        // them (it is blinking); every healthy unit MUST stay pinned at 1.0. If the
        // glitch animation is removed/frozen, node 2's two values become equal → RED.
        let v0 = CodeVault::new(fixture(), Layout3D::Force).with_clock(0.0);
        let v1 = CodeVault::new(fixture(), Layout3D::Force).with_clock(0.30);

        let f0 = v0.state_json();
        let f1 = v1.state_json();
        let g0 = f0["flicker"][2].as_f64().unwrap();
        let g1 = f1["flicker"][2].as_f64().unwrap();
        assert_ne!(g0, g1, "the glitch box must flicker (its value changes with the clock)");
        assert!((0.15..=1.0).contains(&g0) && (0.15..=1.0).contains(&g1), "flicker stays in range");

        for i in [0usize, 1, 3, 4] {
            assert_eq!(f0["flicker"][i].as_f64().unwrap(), 1.0, "healthy unit {i} does not flicker");
            assert_eq!(f1["flicker"][i].as_f64().unwrap(), 1.0, "healthy unit {i} steady across clocks");
        }
        // The changed unit glows steady (amber), not flickering — it's index 4, above.
        assert_eq!(f0["changed_nodes"], serde_json::json!([4]));
    }

    #[test]
    fn material_switch_routes_through_the_single_update_path() {
        let mut v = CodeVault::new(fixture(), Layout3D::Force);
        assert!(v.material().emissive());
        let _ = v.update(CodeVaultMsg::SetMaterial(Material::Solid));
        assert_eq!(v.material(), Material::Solid);
        assert_eq!(v.state_json()["material"], "solid");
        assert_eq!(v.state_json()["fill_alpha"], 1.0, "solid boxes are opaque");
    }

    #[test]
    fn camera_messages_reuse_the_graph3d_model_headlessly() {
        // FC-2 → FC-3: camera/selection is the *reused* Graph3DView, driven through
        // CodeVaultMsg::Camera and snapshotted with the shared harness — no GPU.
        let mut v = CodeVault::new(fixture(), Layout3D::Force);
        let base = v.state().yaw;
        let snap = harness::snapshot(&mut v, [CodeVaultMsg::Camera(CamMsg::DragYaw(10.0))]);
        assert!((snap.yaw - (base + 10.0 * 0.01)).abs() < 1e-6, "drag orbits the camera");
        assert!(!snap.spin, "an orbit drag pauses the auto-spin");

        // A click-select is modelled as SelectNode; it survives a relayout (stable idx).
        let snap = harness::snapshot(&mut v, [CodeVaultMsg::Camera(CamMsg::SelectNode(2)), CodeVaultMsg::Camera(CamMsg::SetLayout(Layout3D::Sphere))]);
        assert_eq!(snap.selected, Some(2), "the picked glitch box stays selected across relayout");
        assert_eq!(snap.layout, Layout3D::Sphere);
    }

    #[test]
    fn a_real_click_picks_a_box_through_the_elm_bridge() {
        // The robot path: render the vault headless, land a real pointer CLICK on a
        // projected box, and assert the selection appeared in state_json (feature id =
        // the node index). Empty state ⇒ no selection yet.
        use facett_core::look::Theme as LookTheme;
        let mut v = CodeVault::new(fixture(), Layout3D::Sphere).with_clock(0.0);
        // Freeze the spin so the projection is stable between the layout frame and the
        // click frame.
        let _ = v.update(CodeVaultMsg::Camera(CamMsg::SetSpin(false)));
        let _ = v.update(CodeVaultMsg::Camera(CamMsg::SetYaw(0.0)));
        assert!(v.state_json()["selected_node"].is_null(), "nothing picked before the click");

        let ctx = egui::Context::default();
        LookTheme::windows_dark().apply(&ctx);
        let size = egui::vec2(400.0, 400.0);
        // Where box 0 projects (center + its screen offset at yaw 0).
        let center = pos2(200.0, 200.0);
        let scale = 400.0 * 0.40;
        let target = v.graph.project_all(center, scale, 0.0)[0].0;

        // Frame 1: paint (no click) so the geometry is laid out at this size.
        let mut run = |ctx: &egui::Context, click: Option<Pos2>| {
            let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(pos2(0.0, 0.0), size)), ..Default::default() };
            if let Some(p) = click {
                input.events.push(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
                input.events.push(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
            }
            ctx.run_ui(input, |ui| {
                egui::CentralPanel::default().show(ui, |ui| v.ui(ui));
            }).textures_delta.clear();
        };
        run(&ctx, None);
        run(&ctx, Some(target));
        assert_eq!(v.state_json()["selected_node"], serde_json::json!(0), "a real click picked box 0 (the feature id)");
    }

    /// A clean tree fixture for the drill/unfold tests: root-a → {child-b, sibling-d};
    /// child-b → leaf-c (leaf-c is reachable ONLY through child-b).
    fn tree() -> CodeGraph {
        let c = Color32::from_rgb(120, 180, 220);
        CodeGraph::new(
            vec![
                CodeNode::new("root-a", c, NodeState::Ok),    // 0
                CodeNode::new("child-b", c, NodeState::Ok),   // 1
                CodeNode::new("leaf-c", c, NodeState::Ok),    // 2
                CodeNode::new("sibling-d", c, NodeState::Ok), // 3
            ],
            vec![(0, 1), (1, 2), (0, 3)],
        )
    }

    #[test]
    fn unfold_in_place_keeps_the_parent_and_ancestors_visible() {
        // RED-when-broken: folding child-b must hide leaf-c but KEEP child-b (and the
        // root). Unfolding child-b brings leaf-c back with the parent still present —
        // if an unfold ever dropped the parent, this fails.
        let mut v = CodeVault::new(tree(), Layout3D::Sphere);
        // Default: fully unfolded → all four boxes visible.
        assert_eq!(v.visible_labels().len(), 4);
        assert_eq!(v.state_json()["visible_count"], 4);

        // Fold child-b (index 1): leaf-c (its exclusive child) disappears, child-b stays.
        let _ = v.update(CodeVaultMsg::Fold(1));
        let vis = v.visible_labels();
        assert!(!vis.contains("leaf-c"), "leaf-c hides when its only parent folds");
        assert!(vis.contains("child-b"), "the folded PARENT itself stays visible");
        assert!(vis.contains("root-a"), "the ancestor stays visible");
        assert_eq!(v.state_json()["visible_count"], 3);

        // Unfold child-b again: leaf-c returns, and the parent is STILL present.
        let _ = v.update(CodeVaultMsg::Unfold(1));
        let vis = v.visible_labels();
        assert!(vis.contains("leaf-c"), "unfold brings the child back");
        assert!(vis.contains("child-b"), "unfold did NOT close the parent");
        assert!(vis.contains("root-a"), "ancestors remain through the unfold");
        assert_eq!(v.visible_labels().len(), 4);
    }

    #[test]
    fn drill_then_back_returns_to_the_parent_level() {
        // Drill descends levels; Back (the right-click) returns to the parent level.
        let mut v = CodeVault::new(tree(), Layout3D::Sphere);
        assert_eq!(v.nav().current_level_id(), "constellation");
        let _ = v.update(CodeVaultMsg::Drill(0)); // → repo
        let _ = v.update(CodeVaultMsg::Drill(1)); // → crate
        assert_eq!(v.nav().current_level_id(), "crate");
        assert_eq!(v.state_json()["nav"]["depth"], 3);
        let _ = v.update(CodeVaultMsg::Back);
        assert_eq!(v.nav().current_level_id(), "repo", "Back returns to the parent level");
        assert_eq!(v.state_json()["nav"]["current_level_id"], "repo");
    }

    #[test]
    fn a_real_right_click_navigates_back_through_the_elm_bridge() {
        // The robot path: drill down, then land a real SECONDARY (right) click and
        // assert the nav popped back a level in state_json.
        use facett_core::look::Theme as LookTheme;
        let mut v = CodeVault::new(tree(), Layout3D::Sphere).with_clock(0.0);
        let _ = v.update(CodeVaultMsg::Camera(CamMsg::SetSpin(false)));
        let _ = v.update(CodeVaultMsg::Drill(0)); // descend to "repo"
        assert_eq!(v.state_json()["nav"]["current_level_id"], "repo");

        let ctx = egui::Context::default();
        LookTheme::windows_dark().apply(&ctx);
        let size = egui::vec2(400.0, 400.0);
        let p = pos2(200.0, 200.0);
        let mut run = |ctx: &egui::Context, click: bool| {
            let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(pos2(0.0, 0.0), size)), ..Default::default() };
            if click {
                input.events.push(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
                input.events.push(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
            }
            ctx.run_ui(input, |ui| {
                egui::CentralPanel::default().show(ui, |ui| v.ui(ui));
            }).textures_delta.clear();
        };
        run(&ctx, false); // warm-up: lay the canvas out at this size
        run(&ctx, true); // the real right-click
        assert_eq!(v.state_json()["nav"]["current_level_id"], "constellation", "a real right-click navigated back to the root");
    }

    #[test]
    fn headless_render_of_the_bridge_draws_boxes_and_reads_state() {
        let mut v = CodeVault::new(fixture(), Layout3D::Force).with_clock(1.0);
        let r = harness::headless_render(&mut v);
        assert!(r.drew(), "the Elm bridge tessellated the vault boxes ({} verts)", r.vertices);
        assert_eq!(r.state["boxes"], 5);
        assert_eq!(r.state["glitch_count"], 1);
    }

    #[test]
    fn with_positions_overrides_the_layout_placement() {
        // The shared-placement seam: a host lays boxes on explicit coordinates (e.g.
        // facett-releaseview's convergence z-planes) and they REPLACE the force layout,
        // so the projection follows the host's planes. RED-when-broken: if set_positions
        // no-ops, a box keeps its laid-out spot and this fails.
        let planes = [[-0.5, 0.2, -0.9], [0.5, -0.2, 0.0], [0.0, 0.7, 0.9], [0.1, 0.1, 0.4], [0.0, 0.0, 0.0]];
        let laid = CodeVault::new(fixture(), Layout3D::Force);
        let before: Vec<[f32; 3]> = laid.graph.nodes.iter().map(|n| n.pos).collect();
        let placed = laid.with_positions(&planes);
        for (i, want) in planes.iter().enumerate() {
            assert_eq!(placed.graph.nodes[i].pos, *want, "box {i} moved to the host plane position");
        }
        // At least one box actually moved off its force-layout spot.
        assert!((0..5).any(|i| placed.graph.nodes[i].pos != before[i]), "positions were overwritten, not ignored");
        // The projection reuses these positions (the same camera the CPU view + GPU lane use).
        let proj = placed.graph.project_all(pos2(100.0, 100.0), 50.0, 0.0);
        assert_eq!(proj.len(), 5);
        assert!(proj.iter().all(|(p, z)| p.x.is_finite() && p.y.is_finite() && z.is_finite()));
    }

    #[test]
    fn empty_vault_is_honest_not_a_crash() {
        let mut v = CodeVault::new(CodeGraph::default(), Layout3D::Sphere);
        let j = v.state_json();
        assert_eq!(j["boxes"], 0);
        assert_eq!(j["glitch_count"], 0);
        let r = harness::headless_render(&mut v);
        assert!(r.drew(), "the empty-vault hint still paints");
    }
}