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