nornir 0.5.0

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! **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, 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::*;
    use super::super::funnel_view::{NodeStat, NodeView};

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