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

nordisk/facett

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