src/viz/funnel3d.rs
//! **Rotating 3-D funnel-plan view** — the funnel DAG as a spinning, depth-shaded
//! node cloud, cycling three layouts (sphere → helix → force).
//!
//! The 3-D layout math (Fibonacci sphere, helix, deterministic 3-D
//! Fruchterman–Reingold) and the projection geometry are the shared facett
//! component [`facett_graph3d::Graph3D`] (at `../facett/facett-graph3d`): nornir
//! is on egui 0.34 now, so the layouts no longer live here — this module is the
//! thin nornir-side adapter that feeds a [`PlanView`] into `Graph3D` and keeps
//! nornir's own observable surface: the themed painter, the
//! `funnel3d_rendered_non_blank` self-emitter, and the
//! `state_json()["funnel"]["graph3d"]` shape the robot-UI matrix asserts. The
//! layout positions are computed by `Graph3D` and read back here.
//!
//! Fed by the funnel tab from the selected [`PlanView`]: one 3-D node per plan
//! node (coloured by lifecycle via [`NodeStat::color_themed`]), one edge per
//! dependency. The view spins on its own; dragging grabs the yaw and pauses the
//! spin. `state_json()` exposes node/edge counts, the active layout, yaw and the
//! spin flag so the robot-UI matrix can assert "see what the user sees".
use eframe::egui::{self, Color32, Pos2, Sense, Stroke, vec2};
use super::facett_theme::Theme;
use super::funnel_view::PlanView;
/// The three layouts the view rotates through (mirrors `facett_graph3d::Layout3D`).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Layout3D {
/// Fibonacci sphere — even spread on a sphere shell.
Sphere,
/// Helix along the Y axis.
Helix,
/// Deterministic 3-D Fruchterman–Reingold — edges pull, all nodes repel.
Force,
}
impl Layout3D {
/// The three layouts in cycle order.
pub const ALL: [Layout3D; 3] = [Layout3D::Sphere, Layout3D::Helix, Layout3D::Force];
pub fn label(self) -> &'static str {
match self {
Layout3D::Sphere => "sphere",
Layout3D::Helix => "helix",
Layout3D::Force => "force",
}
}
/// Next layout in the rotation (sphere → helix → force → sphere).
pub fn next(self) -> Layout3D {
match self {
Layout3D::Sphere => Layout3D::Helix,
Layout3D::Helix => Layout3D::Force,
Layout3D::Force => Layout3D::Sphere,
}
}
/// Map to the shared facett layout (the actual placement algorithm).
fn to_facett(self) -> facett_graph3d::Layout3D {
match self {
Layout3D::Sphere => facett_graph3d::Layout3D::Sphere,
Layout3D::Helix => facett_graph3d::Layout3D::Helix,
Layout3D::Force => facett_graph3d::Layout3D::Force,
}
}
}
struct Node3 {
color: Color32,
pos: [f32; 3],
}
/// A laid-out, spinnable 3-D funnel graph for one plan.
pub struct Funnel3D {
nodes: Vec<Node3>,
edges: Vec<(usize, usize)>,
pub layout: Layout3D,
pub yaw: f32,
pub spin: bool,
/// Which plan id this was built for (so the tab only rebuilds on change).
pub built_for: Option<String>,
}
impl Default for Funnel3D {
fn default() -> Self {
Self {
nodes: Vec::new(),
edges: Vec::new(),
layout: Layout3D::Sphere,
yaw: 0.6,
spin: true,
built_for: None,
}
}
}
impl Funnel3D {
pub fn node_count(&self) -> usize {
self.nodes.len()
}
pub fn edge_count(&self) -> usize {
self.edges.len()
}
/// (Re)build the node cloud from a plan view + theme. Node index = position
/// in `plan.nodes`; edges are `dep -> node` mapped to those indices.
pub fn build(&mut self, plan: &PlanView, theme: &Theme) {
let idx: std::collections::HashMap<&str, usize> = plan
.nodes
.iter()
.enumerate()
.map(|(i, n)| (n.id.as_str(), i))
.collect();
self.nodes = plan
.nodes
.iter()
.map(|n| Node3 {
color: n.status.color_themed(theme),
pos: [0.0; 3],
})
.collect();
self.edges = plan
.nodes
.iter()
.enumerate()
.flat_map(|(to, n)| {
n.deps
.iter()
.filter_map(|d| idx.get(d.as_str()).copied())
.map(move |from| (from, to))
})
.collect();
self.built_for = Some(plan.id.clone());
self.layout_positions();
}
/// (Re)compute node positions for the current layout by delegating to the
/// shared `facett_graph3d::Graph3D` placement, then copying the positions
/// back onto our coloured nodes. The layout algorithms (sphere/helix/force)
/// live in facett-graph3d — this is the only place we cross over.
pub fn layout_positions(&mut self) {
// facett needs (label, color) pairs + edges; the label is unused by the
// layout (placement is index-driven) so we pass empty strings.
let nodes: Vec<(String, Color32)> = self
.nodes
.iter()
.map(|n| (String::new(), n.color))
.collect();
let g = facett_graph3d::Graph3D::new(nodes, self.edges.clone(), self.layout.to_facett());
for (dst, src) in self.nodes.iter_mut().zip(g.nodes.iter()) {
dst.pos = src.pos;
}
}
/// Cycle to the next layout and re-place the nodes.
pub fn cycle_layout(&mut self) {
self.layout = self.layout.next();
self.layout_positions();
}
fn project(&self, p: [f32; 3], center: Pos2, scale: f32) -> (Pos2, f32) {
let (s, c) = self.yaw.sin_cos();
let x = p[0] * c + p[2] * s;
let z = -p[0] * s + p[2] * c;
(center + vec2(x, -p[1]) * scale, z)
}
/// Paint the spinning cloud into `ui`. Advances `yaw` on its own (spin) and
/// grabs it on drag (pausing the spin), exactly like `facett-graph3d`.
pub fn ui(&mut self, ui: &mut egui::Ui, theme: &Theme) {
if self.spin {
let dt = ui.input(|i| i.stable_dt).min(0.1);
self.yaw += dt * 0.4;
ui.ctx().request_repaint();
}
let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::click_and_drag());
if resp.dragged() {
self.yaw += resp.drag_delta().x * 0.01;
self.spin = false;
}
let painter = ui.painter_at(rect);
painter.rect_filled(rect, egui::CornerRadius::ZERO, theme.bg);
if self.nodes.is_empty() {
painter.text(
rect.center(),
egui::Align2::CENTER_CENTER,
"no nodes",
egui::FontId::proportional(13.0),
theme.text_dim,
);
// No emit here: an empty plan (no nodes loaded) is a *legitimate*
// empty state, not a render failure — the "no nodes" placeholder is
// the correct paint for it. Stamping a RED `funnel3d_rendered_non_
// blank` for a never-populated pane (e.g. the all-tabs headless walk
// with no plan seeded) would be a false negative. The dedicated
// `test_render_callgraph_and_funnel3d_nonblank` hook injects a real
// plan (nodes non-empty) and is this surface's positive proof.
return;
}
let center = rect.center();
let scale = rect.size().min_elem() * 0.40;
let proj: Vec<(Pos2, f32)> = self
.nodes
.iter()
.map(|n| self.project(n.pos, center, scale))
.collect();
// edges, faded by mean depth
for &(a, b) in &self.edges {
if a < proj.len() && b < proj.len() {
let depth = (proj[a].1 + proj[b].1) * 0.5;
let alpha = (120.0 + depth * 100.0).clamp(20.0, 220.0);
let col = theme.edge.gamma_multiply(alpha / 255.0);
painter.line_segment([proj[a].0, proj[b].0], Stroke::new(0.7_f32, col));
}
}
// nodes back-to-front: bigger + brighter when nearer (higher z)
let mut order: Vec<usize> = (0..self.nodes.len()).collect();
order.sort_by(|&i, &j| {
proj[i]
.1
.partial_cmp(&proj[j].1)
.unwrap_or(std::cmp::Ordering::Equal)
});
#[cfg(feature = "testmatrix")]
let mut painted = 0usize;
for i in order {
let (p, z) = proj[i];
let r = 4.0 + (z + 1.0) * 3.0;
let bright = (0.5 + (z + 1.0) * 0.25).clamp(0.3, 1.0);
painter.circle_filled(p, r, self.nodes[i].color.gamma_multiply(bright));
#[cfg(feature = "testmatrix")]
{
// count only nodes that projected to a finite on-screen point.
if p.x.is_finite() && p.y.is_finite() {
painted += 1;
}
}
}
// ── emit-fallback (testmatrix only): the 3D projection + paint is a side
// effect. ok ⇔ every node projected to a finite point AND was painted.
#[cfg(feature = "testmatrix")]
crate::selftest::emit(
"viz/funnel3d (egui/3D draw)",
"funnel3d_rendered_non_blank",
painted > 0 && painted == self.nodes.len(),
&format!(
"painted {painted}/{} node sphere(s) + {} edge(s), layout={}",
self.nodes.len(),
self.edges.len(),
self.layout.label(),
),
);
}
/// Robot-observable state — `state_json()["funnel"]["graph3d"]`.
pub fn state_json(&self) -> serde_json::Value {
// How many nodes carry a finite 3D position (the precondition for a
// non-degenerate projection + paint). Exposed as data (LAW 6) so the
// self-emitter matrix can assert `finite_positions == nodes` without
// touching the GPU/egui paint. Always computed (cheap) so the JSON shape
// is identical with/without the testmatrix feature.
let finite_positions = self
.nodes
.iter()
.filter(|n| n.pos.iter().all(|c| c.is_finite()))
.count();
serde_json::json!({
"nodes": self.nodes.len(),
"edges": self.edges.len(),
"finite_positions": finite_positions,
"layout": self.layout.label(),
"yaw": self.yaw,
"spin": self.spin,
"built_for": self.built_for,
})
}
}
#[cfg(test)]
mod tests {
use super::super::funnel_view::{NodeStat, NodeView};
use super::*;
fn plan(n: usize) -> PlanView {
// Chain DAG: node i depends on node i-1.
let nodes = (0..n)
.map(|i| NodeView {
id: format!("n{i}"),
kind: "code:impl".into(),
title: format!("node {i}"),
status: if i == 0 {
NodeStat::Ready
} else {
NodeStat::Pending
},
targets: vec![],
deps: if i == 0 {
vec![]
} else {
vec![format!("n{}", i - 1)]
},
})
.collect();
PlanView {
id: "p-1".into(),
summary: "s".into(),
status: "active".into(),
idea_text: String::new(),
nodes,
}
}
#[test]
fn build_maps_nodes_and_edges_and_layout_is_finite() {
let mut g = Funnel3D::default();
g.build(&plan(8), &Theme::default());
assert_eq!(g.node_count(), 8, "one 3D node per plan node");
assert_eq!(g.edge_count(), 7, "chain DAG → n-1 edges");
assert_eq!(g.built_for.as_deref(), Some("p-1"));
// Sphere layout: every node on the unit shell, finite.
for node in &g.nodes {
let r = (node.pos[0].powi(2) + node.pos[1].powi(2) + node.pos[2].powi(2)).sqrt();
assert!((r - 1.0).abs() < 0.06, "on sphere shell: r={r}");
}
let j = g.state_json();
assert_eq!(j["nodes"], 8);
assert_eq!(j["edges"], 7);
assert_eq!(j["layout"], "sphere");
assert_eq!(j["spin"], true);
}
#[test]
fn cycle_layout_rotates_through_all_three() {
let mut g = Funnel3D::default();
g.build(&plan(6), &Theme::default());
assert_eq!(g.layout, Layout3D::Sphere);
g.cycle_layout();
assert_eq!(g.layout, Layout3D::Helix);
assert_eq!(g.state_json()["layout"], "helix");
g.cycle_layout();
assert_eq!(g.layout, Layout3D::Force);
// Force layout normalises into the unit cube, finite.
for node in &g.nodes {
for c in 0..3 {
assert!(node.pos[c].is_finite(), "finite");
assert!(
node.pos[c] >= -1.06 && node.pos[c] <= 1.06,
"in cube: {}",
node.pos[c]
);
}
}
g.cycle_layout();
assert_eq!(g.layout, Layout3D::Sphere, "wraps back to sphere");
}
#[test]
fn empty_plan_is_safe() {
let mut g = Funnel3D::default();
g.build(&plan(0), &Theme::default());
assert_eq!(g.node_count(), 0);
assert_eq!(g.edge_count(), 0);
// layout on empty must not panic
g.cycle_layout();
assert_eq!(g.state_json()["nodes"], 0);
}
}