use eframe::egui::{self, Color32, Pos2, Sense, Stroke, vec2};
use super::facett_theme::Theme;
use super::funnel_view::PlanView;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Layout3D {
Sphere,
Helix,
Force,
}
impl Layout3D {
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",
}
}
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],
}
pub struct Funnel3D {
nodes: Vec<Node3>,
edges: Vec<(usize, usize)>,
pub layout: Layout3D,
pub yaw: f32,
pub spin: bool,
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()
}
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();
}
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
});
}
}
}
}
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)
}
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();
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));
}
}
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));
}
}
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 {
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"));
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);
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);
g.cycle_layout();
assert_eq!(g.state_json()["nodes"], 0);
}
}