facett-graphview/src/camera3d.rs
//! **The shared CPU 3D camera + placement math** — pure functions, no GPU, no
//! feature gate.
//!
//! These used to live only inside [`world_still`](crate::world_still), which is
//! gated behind feature `gpu` (it rasterises on wgpu with bloom) — so every
//! CPU-side host that wanted the SAME spin (dwarves' `nornir-warehouse-nav` world
//! graph, `nornir-funnel-embed`'s funnel 3D DAG) carried its own byte-identical
//! port of `project_azimuth`, crediting the gate as the reason. That is the twin
//! LAW 5 forbids, fixed by construction: the math lives HERE, ungated, and
//! `world_still` consumes it like everyone else.
/// **Camera-only orthographic projection of a `[-1,1]³` point** — rotate by `yaw`
/// about the **vertical (Y) axis** (the azimuth / spin) then `pitch` about X, and
/// orthographically flatten to **world-space** `[x, y]` (y-up, still in the
/// `[-1,1]` cube, centred on origin); the third component is the view-space
/// **depth** in `[-1,1]` (+ = nearer) for a size/brightness cue.
///
/// This is the projection a LIVE 3D view re-runs each frame to spin a cloud: it
/// touches only the camera angle, never the (cached) layout — so an auto-rotating
/// showpiece advances `yaw` over time while the placement stays put. A host scales
/// the returned `[x, y]` into its own screen units.
#[must_use]
pub fn project_azimuth(p: [f32; 3], yaw: f32, pitch: f32) -> [f32; 3] {
let (sy, cy) = yaw.sin_cos();
let (x1, z1) = (p[0] * cy + p[2] * sy, -p[0] * sy + p[2] * cy);
let (sp, cp) = pitch.sin_cos();
let (y2, z2) = (p[1] * cp - z1 * sp, p[1] * sp + z1 * cp);
[x1, y2, z2]
}
/// The golden angle π(3 − √5), radians — the phyllotaxis constant behind
/// [`fib_sphere`] and [`phyllotaxis_disc`]. Public here (unlike the crate-private
/// `world::GOLDEN_ANGLE` it mirrors) because downstream hosts spelling their own
/// copy is exactly the twin this module deletes.
pub const GOLDEN_ANGLE: f32 = 2.399_963_2;
/// A point on the unit Fibonacci sphere (`i` of `m`) — even spherical distribution,
/// the 3D twin of [`phyllotaxis_disc`].
#[must_use]
pub fn fib_sphere(i: usize, m: usize) -> [f32; 3] {
let m = m.max(1) as f32;
let y = 1.0 - 2.0 * ((i as f32) + 0.5) / m;
let r = (1.0 - y * y).max(0.0).sqrt();
let th = GOLDEN_ANGLE * i as f32;
[r * th.cos(), y, r * th.sin()]
}
/// A golden-angle phyllotaxis point in the unit disc (`j` of `m`) — the even 2D
/// spread hosts use to fan a rank's siblings across a plane (the 2D twin of
/// [`fib_sphere`]).
#[must_use]
pub fn phyllotaxis_disc(j: usize, m: usize) -> [f32; 2] {
if m <= 1 {
return [0.0, 0.0];
}
let t = ((j as f32) + 0.5) / m as f32;
let r = t.sqrt();
let a = GOLDEN_ANGLE * j as f32;
[r * a.cos(), r * a.sin()]
}
/// **Hierarchical 3D galaxy placement** (O(n), no force pass) — repos on a near-cubic 3D
/// grid, each repo's crates on a sphere shell around it, each crate's functions on a small
/// sphere around the crate. Positions in the `[-1,1]³` box. The 3D analogue of
/// [`crate::world::hier_placement`], so the hierarchy is legible as nested shells in space.
#[must_use]
pub fn galaxy_3d(w: &crate::world::WorldGraph) -> Vec<[f32; 3]> {
use crate::world::NodeLevel;
let n = w.n;
let mut pos = vec![[0.0f32; 3]; n];
if n == 0 {
return pos;
}
let repo_count = w.repo_count().max(1);
let side = (repo_count as f64).cbrt().ceil() as usize;
let cell = 2.0f32 / side as f32;
let half = cell * 0.5 * 0.8;
let mut i = 0usize;
while i < n {
let r = w.repo_of[i] as usize;
let start = i;
while i < n && w.repo_of[i] as usize == r {
i += 1;
}
let end = i;
let (rx, ry, rz) = (r % side, (r / side) % side, r / (side * side));
let center = [
-1.0 + (rx as f32 + 0.5) * cell,
-1.0 + (ry as f32 + 0.5) * cell,
-1.0 + (rz as f32 + 0.5) * cell,
];
// Recover crate groups.
let mut repo_node: Option<usize> = None;
let mut crates: Vec<(usize, Vec<usize>)> = Vec::new();
for k in start..end {
match w.level[k] {
NodeLevel::Repo => repo_node = Some(k),
NodeLevel::Crate => crates.push((k, Vec::new())),
NodeLevel::Function => {
if let Some(last) = crates.last_mut() {
last.1.push(k);
} else {
crates.push((start, vec![k]));
}
}
}
}
if let Some(rn) = repo_node {
pos[rn] = center;
}
let c = crates.len().max(1);
let crate_r = if c == 1 { 0.0 } else { half * 0.6 };
let fdisc = (half * 0.32).max(1e-4);
for (ci, (cidx, fns)) in crates.iter().enumerate() {
let s = fib_sphere(ci, c);
let cc = [center[0] + crate_r * s[0], center[1] + crate_r * s[1], center[2] + crate_r * s[2]];
if *cidx != start {
pos[*cidx] = cc;
}
let m = fns.len();
for (j, &f) in fns.iter().enumerate() {
let t = if m > 0 { (((j as f32) + 0.5) / m as f32).cbrt() } else { 0.0 };
let sp = fib_sphere(j, m);
let rr = fdisc * t;
pos[f] = [cc[0] + rr * sp[0], cc[1] + rr * sp[1], cc[2] + rr * sp[2]];
}
}
}
pos
}
#[cfg(test)]
mod tests {
use super::*;
/// Yaw is camera-only about the vertical axis: Y never moves at pitch 0, the
/// XZ radius is preserved, and the input point is untouched (pure fn).
#[test]
fn project_azimuth_spins_about_the_vertical_axis() {
let p = [0.6_f32, -0.3, 0.4];
let a = project_azimuth(p, 0.0, 0.0);
let b = project_azimuth(p, 0.9, 0.0);
assert!((a[1] - b[1]).abs() < 1e-6, "pitch-0 yaw must not move Y");
let ra = (a[0] * a[0] + a[2] * a[2]).sqrt();
let rb = (b[0] * b[0] + b[2] * b[2]).sqrt();
assert!((ra - rb).abs() < 1e-5, "yaw preserves the XZ radius");
assert_ne!(a[0], b[0], "a real yaw moves the projected X");
}
/// Every Fibonacci-sphere point sits ON the unit sphere.
#[test]
fn fib_sphere_points_sit_on_the_unit_sphere() {
for i in 0..64 {
let [x, y, z] = fib_sphere(i, 64);
let r = (x * x + y * y + z * z).sqrt();
assert!((r - 1.0).abs() < 1e-4, "point {i} off the sphere: r={r}");
}
}
/// Every phyllotaxis point stays inside the unit disc, the degenerate 1-of-1
/// fan centres, and two neighbours never coincide (the even-spread property).
#[test]
fn phyllotaxis_disc_points_stay_in_the_unit_disc() {
assert_eq!(phyllotaxis_disc(0, 1), [0.0, 0.0], "a lone sibling centres");
for j in 0..48 {
let [x, y] = phyllotaxis_disc(j, 48);
assert!((x * x + y * y).sqrt() <= 1.0 + 1e-5, "point {j} left the disc");
let [nx, ny] = phyllotaxis_disc(j + 1, 48);
assert!((x - nx).abs() + (y - ny).abs() > 1e-4, "neighbours {j} coincide");
}
}
}