Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/dwarves

crates/nornir-funnel-embed/src/graph3d.rs

//! **The funnel Plan-DAG as a 3D layered graph** — the third render beside the
//! `facett-plandag` card lane and the flat `facett-graphview` node-link graph.
//!
//! A DAG wants *levels*, so this is a **3D layered ("sugiyama-in-space") placement**,
//! not a pure galaxy: the funnel's own dependency rank (borrowed from the engine's
//! [`facett_graphview::DepGraphLayout`] — the SAME toposort layering the 2D graph
//! uses, so the two renders agree on structure) stacks each rank as a plane along the
//! camera's X axis, and the siblings inside a rank fan out on a golden-angle
//! phyllotaxis disc in the Y–Z plane. The result reads as nested slabs in space.
//!
//! ## Reuse (no second renderer, no twin)
//! The **camera + fan math is IMPORTED, not ported**: `project_azimuth` and
//! `phyllotaxis_disc` come from the ungated `facett_graphview::camera3d` (this
//! module used to carry byte-identical CPU ports while the math lived only inside
//! the `gpu`-gated `world_still`; the shared home deleted them — LAW 5). What
//! remains HERE is the funnel's own domain: the layered rank placement over
//! [`facett_graphview::DepGraphLayout`], the lane colours/rings, the labels and
//! the drill — a thin domain painter, the same shape `graph.rs` has for the 2D
//! engine. (`facett-graph3d` is the canonical force/sphere cloud and is CPU-clean
//! too, but a DAG wants ranked slabs, not a galaxy — the layout is the reason this
//! pane exists, and it feeds nothing a `Layout3D` variant offers today.)
//!
//! ## The rotate invariant (camera-only)
//! The `[-1,1]³` layout is built ONCE and cached (keyed on the DAG's signature, NOT
//! on the camera azimuth). **3D rotating** (ON by default — the funnel's showpiece
//! spin) advances only [`yaw`](Self::yaw) and re-projects the cached positions each
//! frame — **0 layout rebuilds** across a spin. [`graph3d_build_calls`] exposes the
//! process-wide build count so the robot suite can assert the layout is reused from
//! cache (the same frame-budget guard as the world graph's `robot_world_3d_rotate`).
//!
//! ## The idea WORDS (labels, ON by default)
//! Every node draws its title — the idea's own words for a mother idea, the task
//! title for a plan node — beside it, depth-cued so the spinning cloud stays legible.
//! The world graph turns labels off at universe scale; the funnel is small, so labels
//! default ON. `ui()` paints exactly [`visible_labels`](FunnelGraph3d::visible_labels)
//! (one list, no twin), and the same list rides in [`report`](FunnelGraph3d::report)
//! as `label_texts` so a robot asserts the actual words attached in 3D.

use std::sync::atomic::{AtomicU64, Ordering};

use egui::{Color32, Pos2, Sense, Stroke, Vec2};
use facett_core::theme;
use facett_graphview::{DepEdge, DepGraphLayout};
use funnel_core::plan_dag::{DagStatus, EdgeKind, PlanDag};

/// Fixed camera pitch (radians) — a gentle tilt so the layered slabs read as 3D even
/// at yaw 0 (mirrors the world graph's `WORLD_3D_PITCH`).
const PITCH: f32 = 0.42;
/// Auto-spin rate (radians/second) — ~15°/s, the world graph's `WORLD_ROTATE_RAD_PER_S`.
const ROTATE_RAD_PER_S: f32 = 0.26;

/// Process-wide count of expensive 3D-layout builds. Public so the black-box robot
/// suite (a separate test binary) can assert the layout is cached across rotation
/// frames (the camera-only invariant).
static BUILD_CALLS: AtomicU64 = AtomicU64::new(0);

/// Total number of times the 3D layered layout was (re)built process-wide. Rotation
/// must never bump this — it re-projects the cached positions, never rebuilds.
#[must_use]
pub fn graph3d_build_calls() -> u64 {
    BUILD_CALLS.load(Ordering::Relaxed)
}

/// The lane a funnel card sits in — the 3D node's colour bucket (idea / plan / active
/// node / done / failed). Mirrors the card vocabulary the pane already speaks.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Lane {
    /// A mother idea (no plan group) — the DAG's roots.
    Idea,
    /// A planned/pending task node (idle).
    Plan,
    /// A task node in flight (ready / in-progress).
    Active,
    /// A completed node.
    Done,
    /// A failed / dropped node.
    Failed,
}

impl Lane {
    /// Classify a funnel card: a mother idea (no `group`) is the `Idea` lane; a task
    /// node takes its lane from its run status.
    fn of(node: &funnel_core::plan_dag::PlanDagNode) -> Self {
        if node.group.is_none() {
            return Lane::Idea;
        }
        match node.status {
            DagStatus::Idle => Lane::Plan,
            DagStatus::Running => Lane::Active,
            DagStatus::Done => Lane::Done,
            DagStatus::Failed => Lane::Failed,
        }
    }

    /// A short stable token (rides in `state_json` / hover text).
    fn key(self) -> &'static str {
        match self {
            Lane::Idea => "idea",
            Lane::Plan => "plan",
            Lane::Active => "node",
            Lane::Done => "done",
            Lane::Failed => "failed",
        }
    }

    /// The lane's node fill, brightened for the dark theme so every lane stays legible
    /// on both backgrounds (theme-aware).
    fn color(self, dark: bool) -> Color32 {
        // (light, dark) pairs — dark variants lifted for contrast on a near-black bg.
        let (l, d) = match self {
            Lane::Idea => ((0x8b, 0x5c, 0xc4), (0xb4, 0x8a, 0xe8)),
            Lane::Plan => ((0x55, 0x5b, 0x69), (0x8a, 0x93, 0xa8)),
            Lane::Active => ((0x2f, 0x5c, 0x8a), (0x5a, 0x9b, 0xe0)),
            Lane::Done => ((0x3c, 0x7a, 0x4e), (0x5fu8, 0xc0, 0x7a)),
            Lane::Failed => ((0x8f, 0x33, 0x33), (0xe0, 0x6a, 0x64)),
        };
        let (r, g, b) = if dark { d } else { l };
        Color32::from_rgb(r, g, b)
    }
}

/// One laid-out 3D node: its cached `[-1,1]³` position (yaw-0 baseline) plus the card
/// facts the render + drill read.
#[derive(Clone, Debug)]
struct Node3d {
    id: String,
    title: String,
    kind: String,
    lane: Lane,
    is_bug: bool,
    /// Cached position in the `[-1,1]³` box — built once, re-projected every frame.
    pos: [f32; 3],
}

/// **Rotate a `[-1,1]³` point by `yaw` (about Y) then `pitch` (about X) and project
/// orthographically**, returning `(screen-space xy in [-1,1]², depth)` where a larger
/// depth is nearer the camera (drives the size + brightness cue and the paint order).
///
/// The camera math is the SHARED `facett_graphview::camera3d::project_azimuth` — the
/// ungated home the byte-identical CPU port that used to live here moved to
/// (LAW 5: the port existed only because the fn sat inside the `gpu`-gated
/// `world_still`). This wrapper just splits its `[x, y, depth]` into the
/// `(xy, depth)` shape the paint loop consumes.
fn project_azimuth(p: [f32; 3], yaw: f32, pitch: f32) -> ([f32; 2], f32) {
    let [x, y, depth] = facett_graphview::camera3d::project_azimuth(p, yaw, pitch);
    ([x, y], depth)
}

// The golden-angle phyllotaxis fan is the shared math too — same home.
use facett_graphview::camera3d::phyllotaxis_disc;

/// Longest label painted beside a 3D node — long idea text is elided so a spinning
/// cloud of words stays legible (the words are the point; a paragraph is not).
const LABEL_MAX_CHARS: usize = 48;

/// Shape a node title into its painted 3D label: trimmed, elided at
/// [`LABEL_MAX_CHARS`] with `…`. The one shaping both `ui()` and `visible_labels()`
/// go through, so the reported text IS the painted text.
fn label_text(title: &str) -> String {
    let t = title.trim();
    if t.chars().count() <= LABEL_MAX_CHARS {
        t.to_string()
    } else {
        let mut s: String = t.chars().take(LABEL_MAX_CHARS - 1).collect();
        s.push('…');
        s
    }
}

/// A cheap, stable signature of a DAG's structure + node states — the layout cache key.
/// Changes iff a node/edge is added/removed or a node's title/lane/kind changes; the
/// camera azimuth is deliberately NOT part of it.
fn dag_signature(dag: &PlanDag) -> u64 {
    use std::hash::{Hash, Hasher};
    let mut h = std::collections::hash_map::DefaultHasher::new();
    dag.nodes.len().hash(&mut h);
    for n in &dag.nodes {
        n.id.hash(&mut h);
        n.title.hash(&mut h);
        n.status.as_str().hash(&mut h);
        n.group.hash(&mut h);
        n.is_bug.hash(&mut h);
    }
    dag.edges.len().hash(&mut h);
    for e in &dag.edges {
        e.from.hash(&mut h);
        e.to.hash(&mut h);
        e.kind.as_str().hash(&mut h);
    }
    h.finish()
}

/// **The funnel Plan-DAG rendered as a 3D layered graph** — a measured pane with a
/// camera-only 3D toggle + optional auto-rotate + clickable drill.
pub struct FunnelGraph3d {
    /// Laid-out nodes (cached `[-1,1]³` positions).
    nodes: Vec<Node3d>,
    /// Edges as `(from_idx, to_idx, is_dep)` — dependency edges paint solid, the
    /// idea→node "mother" decomposition edges paint dimmer.
    edges: Vec<(usize, usize, bool)>,
    /// The signature of the DAG the current layout was built for (cache key).
    sig: Option<u64>,
    /// Live camera azimuth (radians). Advances while rotating; fed ONLY to the
    /// projection, never the layout. Reported as `rotate_angle`.
    yaw: f32,
    /// Auto-spin the camera about the vertical axis. ON by default — the funnel's
    /// 3D render is a slowly spinning graph out of the box.
    rotate: bool,
    /// Draw every node's title (the idea words) beside it. ON by default — the
    /// funnel is small; only the world graph's universe scale turns labels off.
    labels: bool,
    /// Selected node index (the drill target) — `None` = nothing picked.
    selected: Option<usize>,
    /// Wall time of the last painted frame, microseconds.
    last_frame_us: Option<u128>,
    /// Instance-local count of genuine layout rebuilds (the per-pane twin of the
    /// process-wide [`graph3d_build_calls`]). Deterministic regardless of what other
    /// panes/tests do, so an in-process unit test can assert the cache exactly.
    builds: u64,
}

impl Default for FunnelGraph3d {
    fn default() -> Self {
        Self::new()
    }
}

impl FunnelGraph3d {
    /// An empty pane.
    pub fn new() -> Self {
        Self {
            nodes: Vec::new(),
            edges: Vec::new(),
            sig: None,
            yaw: 0.0,
            rotate: true,
            labels: true,
            selected: None,
            last_frame_us: None,
            builds: 0,
        }
    }

    /// Instance-local number of genuine layout rebuilds so far (0 across identical
    /// re-polls + rotation — the camera-only invariant, per pane).
    pub fn build_count(&self) -> u64 {
        self.builds
    }

    /// **Replace the graph from a [`PlanDag`] — cached.** The expensive 3D layered
    /// placement runs ONLY when the DAG's signature changed; a re-poll with an identical
    /// DAG is a no-op (so auto-poll + rotation never rebuild the layout). Bumps
    /// [`graph3d_build_calls`] exactly once per genuine rebuild.
    pub fn set_dag(&mut self, dag: &PlanDag) {
        let sig = dag_signature(dag);
        if self.sig == Some(sig) {
            return; // unchanged — reuse the cached layout (the camera-only invariant)
        }
        self.rebuild(dag);
        self.sig = Some(sig);
        self.builds += 1;
        BUILD_CALLS.fetch_add(1, Ordering::Relaxed);
    }

    /// Build the cached `[-1,1]³` layered placement. Ranks come from the engine's
    /// `DepGraphLayout` (the SAME toposort the 2D graph uses); each rank is a plane
    /// stacked along X, its siblings fanned across a phyllotaxis disc in Y–Z.
    fn rebuild(&mut self, dag: &PlanDag) {
        // Borrow the engine's column (rank) layering — deps flow left→right.
        let ids: Vec<String> = dag.nodes.iter().map(|n| n.id.clone()).collect();
        let dep_edges: Vec<DepEdge> = dag
            .edges
            .iter()
            .map(|e| DepEdge::new(e.from.clone(), e.to.clone(), &[]))
            .collect();
        let laid = DepGraphLayout::build(&ids, &dep_edges, false, &Default::default());

        let mut col_of: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
        let mut max_col = 0usize;
        for n in &laid.nodes {
            max_col = max_col.max(n.col);
            col_of.insert(n.repo.as_str(), n.col);
        }
        // A node the rank walk didn't reach (an isolated idea) goes to a trailing rank,
        // so it is placed, never dropped.
        let spill_col = max_col + 1;
        let node_col: Vec<usize> = dag
            .nodes
            .iter()
            .map(|n| col_of.get(n.id.as_str()).copied().unwrap_or(spill_col))
            .collect();
        let ncols = node_col.iter().copied().max().unwrap_or(0);
        // Within-rank index for each node (order of appearance keeps it stable).
        let mut per_col: std::collections::BTreeMap<usize, usize> =
            std::collections::BTreeMap::new();
        let mut counts: std::collections::BTreeMap<usize, usize> =
            std::collections::BTreeMap::new();
        for &c in &node_col {
            *counts.entry(c).or_insert(0) += 1;
        }
        let mut row_of = vec![0usize; dag.nodes.len()];
        for (i, c) in node_col.iter().copied().enumerate() {
            let r = per_col.entry(c).or_insert(0);
            row_of[i] = *r;
            *r += 1;
        }

        // The disc radius per rank: shrinks a touch as ranks get denser so a big rank
        // stays inside the [-1,1] box; a lone node sits dead-centre on its plane.
        const DISC_R: f32 = 0.86;
        self.nodes = dag
            .nodes
            .iter()
            .enumerate()
            .map(|(i, n)| {
                let col = node_col[i];
                let x = if ncols == 0 {
                    0.0
                } else {
                    -0.9 + 1.8 * (col as f32 / ncols as f32)
                };
                let m = counts.get(&col).copied().unwrap_or(1);
                let d = phyllotaxis_disc(row_of[i], m);
                let title = if n.title.trim().is_empty() {
                    n.id.clone()
                } else {
                    n.title.clone()
                };
                Node3d {
                    id: n.id.clone(),
                    title,
                    kind: n
                        .group
                        .clone()
                        .map(|_| "node".to_string())
                        .unwrap_or_else(|| "idea".to_string()),
                    lane: Lane::of(n),
                    is_bug: n.is_bug,
                    pos: [x, d[0] * DISC_R, d[1] * DISC_R],
                }
            })
            .collect();

        let idx: std::collections::HashMap<&str, usize> = dag
            .nodes
            .iter()
            .enumerate()
            .map(|(i, n)| (n.id.as_str(), i))
            .collect();
        self.edges = dag
            .edges
            .iter()
            .filter_map(|e| {
                let (&from, &to) = (idx.get(e.from.as_str())?, idx.get(e.to.as_str())?);
                Some((from, to, e.kind == EdgeKind::Dep))
            })
            .collect();
        // Drop a stale selection that no longer exists.
        if let Some(s) = self.selected {
            if s >= self.nodes.len() {
                self.selected = None;
            }
        }
    }

    /// Turn the camera auto-spin on/off (the "3D rotating" toggle).
    pub fn set_rotate(&mut self, on: bool) {
        self.rotate = on;
    }

    /// Whether the camera is auto-spinning.
    pub fn is_rotating(&self) -> bool {
        self.rotate
    }

    /// Turn the per-node labels (the idea words) on/off.
    pub fn set_labels(&mut self, on: bool) {
        self.labels = on;
    }

    /// Whether per-node labels are drawn.
    pub fn labels_on(&self) -> bool {
        self.labels
    }

    /// The node indices whose labels draw this frame: ALL nodes while labels are on
    /// (the funnel is small — the nav crate's universe scale is where labels go off),
    /// else only the selection. The ONE label list: [`ui`](Self::ui) paints exactly
    /// these and [`visible_labels`](Self::visible_labels)/[`report`](Self::report)
    /// project the same list — the robot asserts what is actually painted, no twin.
    fn label_indices(&self) -> Vec<usize> {
        if self.labels {
            (0..self.nodes.len()).collect()
        } else {
            self.selected
                .into_iter()
                .filter(|&i| i < self.nodes.len())
                .collect()
        }
    }

    /// The label texts drawn this frame (the idea words attached in 3D), in node
    /// order — exactly what [`ui`](Self::ui) paints, after [`label_text`] shaping.
    pub fn visible_labels(&self) -> Vec<String> {
        self.label_indices()
            .into_iter()
            .map(|i| label_text(&self.nodes[i].title))
            .collect()
    }

    /// The live camera azimuth (radians).
    pub fn yaw(&self) -> f32 {
        self.yaw
    }

    /// The selected node id (the drill target), if any.
    pub fn selected_id(&self) -> Option<&str> {
        self.selected
            .and_then(|i| self.nodes.get(i))
            .map(|n| n.id.as_str())
    }

    /// **Select a node by id — the headless twin of a click (the drill seam).** Returns
    /// the id when it exists (so the pane can forward it to the card view for the
    /// drill), else `None`. Lets a robot drive the drill over the JSON seam, exactly as
    /// a pointer click does in [`ui`](Self::ui).
    pub fn select_id(&mut self, id: &str) -> Option<String> {
        if let Some(i) = self.nodes.iter().position(|n| n.id == id) {
            self.selected = Some(i);
            Some(self.nodes[i].id.clone())
        } else {
            None
        }
    }

    /// Nodes in the 3D model (the WHOLE funnel — no cap).
    pub fn node_count(&self) -> usize {
        self.nodes.len()
    }

    /// Edges in the 3D model.
    pub fn edge_count(&self) -> usize {
        self.edges.len()
    }

    /// Wall time of the last painted frame, microseconds.
    pub fn last_frame_us(&self) -> Option<u128> {
        self.last_frame_us
    }

    /// Advance the camera azimuth by `dt` seconds of spin (pure — no clock). Exposed so
    /// a headless test can rotate without a real animation loop.
    pub fn advance(&mut self, dt: f32) {
        if self.rotate {
            self.yaw = (self.yaw + ROTATE_RAD_PER_S * dt).rem_euclid(std::f32::consts::TAU);
        }
    }

    /// **Paint the 3D layered DAG, timing the frame.** Returns `Some(id)` when a node
    /// was clicked this frame (the pane forwards it to the card view for the drill).
    /// Re-projects the cached layout at the current yaw — never rebuilds it.
    pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<String> {
        let t0 = std::time::Instant::now();
        let th = theme(ui);
        let dark = th.bg.r() < 128;

        // Auto-spin: advance the azimuth by real (clamped) frame time and keep the loop
        // ticking. PROJECTION-ONLY — the layout stays cached (0 rebuilds).
        if self.rotate {
            let dt = ui.input(|i| i.stable_dt).min(0.1);
            self.advance(dt);
            ui.ctx().request_repaint();
        }

        let avail = ui.available_size();
        let canvas = Vec2::new(avail.x.max(240.0), avail.y.max(320.0));
        let (rect, resp) = ui.allocate_exact_size(canvas, Sense::click());
        let painter = ui.painter_at(rect);
        painter.rect_filled(rect, 0.0, th.bg);

        if self.nodes.is_empty() {
            painter.text(
                rect.center(),
                egui::Align2::CENTER_CENTER,
                "no funnel to render in 3D",
                egui::FontId::proportional(14.0),
                th.text_dim,
            );
            self.last_frame_us = Some(t0.elapsed().as_micros());
            return None;
        }

        // Project every cached position at the current camera azimuth.
        let half = (rect.width().min(rect.height())) * 0.5 * 0.86;
        let center = rect.center();
        let proj: Vec<(Pos2, f32)> = self
            .nodes
            .iter()
            .map(|n| {
                let ([sx, sy], depth) = project_azimuth(n.pos, self.yaw, PITCH);
                (Pos2::new(center.x + sx * half, center.y - sy * half), depth)
            })
            .collect();

        // Edges under the nodes — dependency edges solid on the theme edge colour, the
        // mother (decomposition) edges dimmer.
        for &(a, b, is_dep) in &self.edges {
            let (pa, pb) = (proj[a].0, proj[b].0);
            let col = if is_dep {
                th.edge
            } else {
                Color32::from_rgba_unmultiplied(th.edge.r(), th.edge.g(), th.edge.b(), 90)
            };
            painter.line_segment(
                [pa, pb],
                Stroke::new(if is_dep { 1.4_f32 } else { 1.0_f32 }, col),
            );
        }

        // Nodes, painted back-to-front (nearest last) so depth reads correctly.
        let base_r = (900.0 / self.nodes.len().max(1) as f32)
            .sqrt()
            .clamp(4.0, 11.0);
        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, depth) = proj[i];
            let n = &self.nodes[i];
            // Depth cue: nearer (+depth) → larger + brighter.
            let dcue = 0.62 + 0.38 * (depth * 0.5 + 0.5).clamp(0.0, 1.0);
            let r = base_r * dcue;
            let base = n.lane.color(dark);
            let fill = Color32::from_rgb(
                (base.r() as f32 * dcue) as u8,
                (base.g() as f32 * dcue) as u8,
                (base.b() as f32 * dcue) as u8,
            );
            painter.circle_filled(p, r, fill);
            // Selection ring (accent) / bug ring (warm) / a thin edge otherwise.
            if self.selected == Some(i) {
                painter.circle_stroke(p, r + 2.5, Stroke::new(2.5_f32, th.accent));
            } else if n.is_bug {
                painter.circle_stroke(
                    p,
                    r + 1.5,
                    Stroke::new(2.0_f32, Color32::from_rgb(0xd9, 0x53, 0x4f)),
                );
            } else {
                painter.circle_stroke(p, r, Stroke::new(1.0_f32, th.node_stroke));
            }
        }

        // The idea WORDS, attached in 3D: paint every labelled node's title beside it
        // (the whole funnel while labels are on — the default; selection-only when
        // off), depth-cued so the spinning cloud reads front-to-back. The selected
        // node's label is emphasised (full text colour, larger font — the drill
        // affordance). Paints EXACTLY `label_indices()` — the list `visible_labels()`
        // reports, so `state_json.graph3d.label_texts` is the painted truth.
        for i in self.label_indices() {
            let (p, depth) = proj[i];
            let n = &self.nodes[i];
            let dcue = 0.62 + 0.38 * (depth * 0.5 + 0.5).clamp(0.0, 1.0);
            let r = base_r * dcue;
            let (font, col) = if self.selected == Some(i) {
                (egui::FontId::proportional(12.5), th.text)
            } else {
                // Farther → dimmer, but never below legibility.
                let a = (110.0 + 145.0 * (depth * 0.5 + 0.5).clamp(0.0, 1.0)) as u8;
                (
                    egui::FontId::proportional(11.0),
                    Color32::from_rgba_unmultiplied(th.text.r(), th.text.g(), th.text.b(), a),
                )
            };
            painter.text(
                p + Vec2::new(r + 5.0, 0.0),
                egui::Align2::LEFT_CENTER,
                label_text(&n.title),
                font,
                col,
            );
        }

        // Click → pick the nearest node within a generous radius → select + drill.
        let mut clicked_id: Option<String> = None;
        if resp.clicked() {
            if let Some(pos) = resp.interact_pointer_pos() {
                let mut best: Option<(usize, f32)> = None;
                for (i, (p, depth)) in proj.iter().enumerate() {
                    let dcue = 0.62 + 0.38 * (depth * 0.5 + 0.5).clamp(0.0, 1.0);
                    let hit = (base_r * dcue + 5.0).max(8.0);
                    let d2 = (p.x - pos.x).powi(2) + (p.y - pos.y).powi(2);
                    if d2 <= hit * hit && best.map(|(_, bd)| d2 < bd).unwrap_or(true) {
                        best = Some((i, d2));
                    }
                }
                if let Some((i, _)) = best {
                    self.selected = Some(i);
                    clicked_id = Some(self.nodes[i].id.clone());
                }
            }
        }

        self.last_frame_us = Some(t0.elapsed().as_micros());
        clicked_id
    }

    /// The measurement + selection, as `state_json` data.
    pub fn report(&self) -> serde_json::Value {
        let selected = self.selected.and_then(|i| self.nodes.get(i));
        serde_json::json!({
            "nodes": self.nodes.len(),
            "edges": self.edges.len(),
            "rotate": self.rotate,
            "rotate_angle": self.yaw,
            // The idea words attached in 3D: whether labels are on, and the exact
            // texts `ui()` paints (same list, no twin) — assert the WORDS, not counts.
            "labels": self.labels,
            "label_texts": self.visible_labels(),
            "last_frame_us": self.last_frame_us,
            // No MAX_NODES ceiling (unlike nornir's CallGraph pane).
            "node_cap": serde_json::Value::Null,
            "selected": selected.map(|n| n.id.clone()),
            "selected_title": selected.map(|n| n.title.clone()),
            "selected_lane": selected.map(|n| n.lane.key()),
            "selected_kind": selected.map(|n| n.kind.clone()),
            // The distinct lanes present (idea/plan/node/done/failed) — proof the render
            // colours by lane/state, as data.
            "lanes": {
                "idea": self.nodes.iter().filter(|n| n.lane == Lane::Idea).count(),
                "plan": self.nodes.iter().filter(|n| n.lane == Lane::Plan).count(),
                "node": self.nodes.iter().filter(|n| n.lane == Lane::Active).count(),
                "done": self.nodes.iter().filter(|n| n.lane == Lane::Done).count(),
                "failed": self.nodes.iter().filter(|n| n.lane == Lane::Failed).count(),
            },
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use funnel_core::plan_dag::{PlanDagEdge, PlanDagNode};

    fn node(id: &str, title: &str, group: Option<&str>, status: DagStatus) -> PlanDagNode {
        PlanDagNode {
            id: id.into(),
            title: title.into(),
            group: group.map(|s| s.into()),
            status,
            is_bug: false,
            staleness_hours: 0.0,
            weight_bytes: 0,
            fanout: 0,
        }
    }

    /// A small ideas→plan→nodes fixture DAG: one idea, three task nodes wired
    /// idea→a→b→c, plus a second idea with one node.
    fn fixture() -> PlanDag {
        let nodes = vec![
            node("i-001", "release engine", None, DagStatus::Running),
            node("n-001", "write", Some("p-001"), DagStatus::Done),
            node("n-002", "fix", Some("p-001"), DagStatus::Running),
            node("n-003", "test", Some("p-001"), DagStatus::Idle),
            node("i-002", "flaky test", None, DagStatus::Idle),
            node("n-004", "triage", Some("p-002"), DagStatus::Failed),
        ];
        let edges = vec![
            PlanDagEdge {
                from: "i-001".into(),
                to: "n-001".into(),
                kind: EdgeKind::Mother,
            },
            PlanDagEdge {
                from: "n-001".into(),
                to: "n-002".into(),
                kind: EdgeKind::Dep,
            },
            PlanDagEdge {
                from: "n-002".into(),
                to: "n-003".into(),
                kind: EdgeKind::Dep,
            },
            PlanDagEdge {
                from: "i-002".into(),
                to: "n-004".into(),
                kind: EdgeKind::Mother,
            },
        ];
        PlanDag {
            nodes,
            edges,
            sel_plan: None,
        }
    }

    #[test]
    fn lowers_every_node_and_edge_into_3d() {
        let mut g = FunnelGraph3d::new();
        g.set_dag(&fixture());
        assert_eq!(g.node_count(), 6, "every funnel node lowered");
        assert_eq!(g.edge_count(), 4, "every edge lowered");
        // A true 3D spread — real extent on all three axes (not a flat sheet / a point).
        for d in 0..3 {
            let lo = g.nodes.iter().map(|n| n.pos[d]).fold(f32::MAX, f32::min);
            let hi = g.nodes.iter().map(|n| n.pos[d]).fold(f32::MIN, f32::max);
            assert!(hi - lo > 0.1, "axis {d} spreads (extent {:.3})", hi - lo);
        }
    }

    #[test]
    fn colours_by_lane_and_state() {
        let mut g = FunnelGraph3d::new();
        g.set_dag(&fixture());
        let r = g.report();
        assert_eq!(r["lanes"]["idea"], 2, "two mother ideas");
        assert_eq!(r["lanes"]["done"], 1);
        assert_eq!(r["lanes"]["node"], 1, "one running task node");
        assert_eq!(r["lanes"]["failed"], 1);
        assert!(r["node_cap"].is_null());
    }

    /// **The idea WORDS are attached in the 3D graph.** Labels default ON (the funnel
    /// is small) and the visible label list carries the actual idea text — asserted as
    /// TEXT, not counts. Turning labels off collapses the list to the selection only.
    #[test]
    fn labels_carry_the_idea_words() {
        let mut g = FunnelGraph3d::new();
        g.set_dag(&fixture());
        assert!(
            g.labels_on(),
            "labels default ON for the funnel (small data)"
        );
        assert!(g.is_rotating(), "the 3D funnel graph spins by default");

        let labels = g.visible_labels();
        assert_eq!(labels.len(), g.node_count(), "every node carries its words");
        // The IDEAS' words, verbatim (the fixture's idea titles are idea text).
        assert!(
            labels.iter().any(|l| l == "release engine"),
            "idea words present: {labels:?}"
        );
        assert!(
            labels.iter().any(|l| l == "flaky test"),
            "second idea's words present: {labels:?}"
        );
        // Task-node words ride too (the plan's own vocabulary).
        assert!(
            labels.iter().any(|l| l == "write"),
            "task words present: {labels:?}"
        );
        // The same texts ride in `report()` for the robot seam.
        let r = g.report();
        assert_eq!(r["labels"], true);
        let reported: Vec<&str> = r["label_texts"]
            .as_array()
            .unwrap()
            .iter()
            .filter_map(|v| v.as_str())
            .collect();
        assert!(
            reported.contains(&"release engine"),
            "report carries the words: {reported:?}"
        );

        // Labels OFF → only the selection keeps its label (none selected ⇒ none).
        g.set_labels(false);
        assert!(
            g.visible_labels().is_empty(),
            "labels off + nothing selected ⇒ no labels"
        );
        g.select_id("i-002").expect("fixture node selectable");
        assert_eq!(
            g.visible_labels(),
            vec!["flaky test".to_string()],
            "selection keeps its words"
        );

        // A long idea elides at LABEL_MAX_CHARS but keeps its leading words.
        let long = "a".repeat(LABEL_MAX_CHARS * 2);
        let shaped = label_text(&long);
        assert_eq!(shaped.chars().count(), LABEL_MAX_CHARS, "elided to the cap");
        assert!(shaped.ends_with('…'));
    }

    /// **The camera-only invariant, at the model layer.** Re-feeding an identical DAG
    /// does NOT rebuild the layout, and advancing the azimuth never rebuilds either —
    /// only a genuine DAG change does.
    #[test]
    fn identical_dag_and_rotation_do_not_rebuild() {
        let mut g = FunnelGraph3d::new();
        let dag = fixture();
        g.set_dag(&dag);
        // Instance-local counter is deterministic even with other tests running in the
        // same process (the process-wide `graph3d_build_calls` is for the isolated robot
        // binary). One rebuild so far (the first `set_dag`).
        assert_eq!(g.build_count(), 1);
        // Re-poll the same DAG many times — 0 further rebuilds.
        for _ in 0..10 {
            g.set_dag(&dag);
        }
        // Spin the camera — 0 rebuilds, and the PROJECTION really moves: an off-axis
        // node's projected screen position differs after the spin while its cached
        // 3D layout position is bit-identical (camera-only, applied output).
        g.set_rotate(true);
        let before_yaw = g.yaw();
        let probe = g
            .nodes
            .iter()
            .position(|n| n.pos[0].abs() + n.pos[2].abs() > 1e-3)
            .expect("an off-axis node exists to probe the projection");
        let pos_before = g.nodes[probe].pos;
        let (xy_before, _) = project_azimuth(pos_before, g.yaw(), PITCH);
        for _ in 0..24 {
            g.advance(0.05);
        }
        assert!(g.yaw() > before_yaw, "the azimuth advanced");
        let (xy_after, _) = project_azimuth(g.nodes[probe].pos, g.yaw(), PITCH);
        let moved = (xy_after[0] - xy_before[0]).abs() + (xy_after[1] - xy_before[1]).abs();
        assert!(
            moved > 1e-3,
            "rotation advanced the projection (Δ={moved:.5})"
        );
        assert_eq!(
            g.nodes[probe].pos, pos_before,
            "the cached layout position never moved"
        );
        assert_eq!(
            g.build_count(),
            1,
            "no rebuild across identical polls + rotation"
        );
        // A genuine change (add a node) DOES rebuild exactly once more.
        let mut grown = fixture();
        grown
            .nodes
            .push(node("n-009", "extra", Some("p-001"), DagStatus::Idle));
        g.set_dag(&grown);
        assert_eq!(g.build_count(), 2, "a changed DAG rebuilds once");
    }
}