nornir 0.4.40

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).
//!
//! This is the egui-0.33-native sibling of facett's `facett-graph3d::Graph3D`
//! (which lives at `../facett/facett-graph3d`): the SAME projection (orthographic
//! + a Y-rotation, depth → size/brightness) and the SAME three [`Layout3D`]s
//! (Fibonacci sphere, helix, deterministic 3-D Fruchterman–Reingold). nornir is
//! pinned to egui 0.33 (egui-snarl 0.9), facett to 0.34, so we can't depend on
//! `facett-graph3d` directly yet; when nornir adopts egui 0.34 this should be
//! cut over to the shared facett component (the math is intentionally identical
//! so the `state_json()` shapes match for the test matrix).
//!
//! 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 (matches `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,
        }
    }
}

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.
    pub fn layout_positions(&mut self) {
        let n = self.nodes.len();
        match self.layout {
            Layout3D::Sphere => {
                let golden = std::f32::consts::PI * (3.0 - 5.0_f32.sqrt());
                for (i, node) in self.nodes.iter_mut().enumerate() {
                    let y = if n > 1 { 1.0 - (i as f32 / (n - 1) as f32) * 2.0 } else { 0.0 };
                    let r = (1.0 - y * y).max(0.0).sqrt();
                    let th = golden * i as f32;
                    node.pos = [th.cos() * r, y, th.sin() * r];
                }
            }
            Layout3D::Helix => {
                for (i, node) in self.nodes.iter_mut().enumerate() {
                    let t = if n > 1 { i as f32 / (n - 1) as f32 } else { 0.5 };
                    let a = t * std::f32::consts::TAU * 3.0;
                    node.pos = [a.cos(), t * 2.0 - 1.0, a.sin()];
                }
            }
            Layout3D::Force => {
                let golden = std::f32::consts::PI * (3.0 - 5.0_f32.sqrt());
                let mut p: Vec<[f32; 3]> = (0..n)
                    .map(|i| {
                        let y = if n > 1 { 1.0 - (i as f32 / (n - 1) as f32) * 2.0 } else { 0.0 };
                        let r = (1.0 - y * y).max(0.0).sqrt();
                        let th = golden * i as f32;
                        [th.cos() * r, y, th.sin() * r]
                    })
                    .collect();
                let k = (1.0 / (n.max(1) as f32).cbrt()).clamp(0.05, 1.0);
                let sub = |a: [f32; 3], b: [f32; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
                let len = |a: [f32; 3]| (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt();
                for _ in 0..100 {
                    let mut disp = vec![[0.0f32; 3]; n];
                    for i in 0..n {
                        for j in (i + 1)..n {
                            let d = sub(p[i], p[j]);
                            let dist = len(d).max(1e-3);
                            let f = k * k / dist / dist;
                            for c in 0..3 {
                                disp[i][c] += d[c] * f;
                                disp[j][c] -= d[c] * f;
                            }
                        }
                    }
                    for &(s, t) in &self.edges {
                        if s < n && t < n {
                            let d = sub(p[s], p[t]);
                            let dist = len(d).max(1e-3);
                            let f = dist / k;
                            for c in 0..3 {
                                disp[s][c] -= d[c] * f;
                                disp[t][c] += d[c] * f;
                            }
                        }
                    }
                    for i in 0..n {
                        let dl = len(disp[i]).max(1e-3);
                        let step = dl.min(0.04) / dl;
                        for c in 0..3 {
                            p[i][c] += disp[i][c] * step;
                        }
                    }
                }
                let (mut mn, mut mx) = ([f32::MAX; 3], [f32::MIN; 3]);
                for v in &p {
                    for c in 0..3 {
                        mn[c] = mn[c].min(v[c]);
                        mx[c] = mx[c].max(v[c]);
                    }
                }
                for (node, v) in self.nodes.iter_mut().zip(p) {
                    node.pos = [0, 1, 2].map(|c| {
                        let span = (mx[c] - mn[c]).max(1e-3);
                        ((v[c] - mn[c]) / span - 0.5) * 2.0
                    });
                }
            }
        }
    }

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

    /// Robot-observable state — `state_json()["funnel"]["graph3d"]`.
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "nodes": self.nodes.len(),
            "edges": self.edges.len(),
            "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);
    }
}