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