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

nordisk/facett

facett-graph3d/src/rate.rs

//! **EDGE-RATE overlay** (feature `rate`) — the 3D sibling of facett-flow's
//! `rate` feature. The consumer sets per-edge rates ([`EdgeRates`]: one
//! `Option<f32>` per edge, indexed like [`Graph3D::edges`](crate::Graph3D)),
//! and the injected animation clock (FC-7) drives PULSES travelling along a
//! **curved** 3D arc per rated edge — brighter/faster for a higher rate,
//! nothing drawn for `None`, zero behaviour change when the feature is off or
//! no rates are set.
//!
//! The curve is the owner's SMOOTH-lines requirement carried into 3D: a
//! quadratic Bézier through a midpoint lifted perpendicular to the chord
//! (the house convention is the 🏛 room's call splines — `spline_points` /
//! `arc_points` in dwarves' coderoom3d; this is the generic, chord-relative
//! spelling of the same bow). The sampled 3D points are PROJECTED through the
//! same camera the cloud paints with, then each segment is depth-shaded —
//! so the arc sits IN the scene, not on top of it.
//!
//! Pulses interpolate along the curve's **arc length** (not the chord), and
//! the clock drives a fractional phase every frame, so the motion is
//! continuously smooth at any frame rate.

use serde::{Deserialize, Serialize};

/// Per-edge rates, indexed like the host's edge list. `None` = unrated (that
/// edge draws exactly as before). The 3D twin of `facett_flow::attr::Rates`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct EdgeRates {
    pub edges: Vec<Option<f32>>,
}

/// Curve sample count — enough segments that the arc looks smooth at typical
/// zoom (the 🏛 room samples its call splines at 16–24; this sits at the top
/// of that band because the overlay's arcs are the pane's foreground).
pub const CURVE_SEGMENTS: usize = 24;

/// How far the curve's midpoint is lifted, as a fraction of the chord length.
pub const CURVE_BOW: f32 = 0.25;

/// The slowest pulse (cycles/sec) — a just-rated edge still visibly moves.
pub const PULSE_MIN_HZ: f32 = 0.25;
/// The fastest pulse (cycles/sec) — the hottest edge races but stays readable.
pub const PULSE_MAX_HZ: f32 = 2.0;
/// Pulses per rated edge, spaced evenly along the arc so the edge reads as a
/// continuous flow rather than one lonely dot.
pub const PULSES_PER_EDGE: usize = 2;

/// **The 3D arc for a rated edge**: a quadratic Bézier `a → m' → b` where `m'`
/// is the chord midpoint lifted perpendicular to the chord by
/// [`CURVE_BOW`]·|chord|. Returns `n + 1` sampled points (so `n` segments).
/// Degenerate chords (zero length) return the two endpoints.
#[must_use]
pub fn edge_curve(a: [f32; 3], b: [f32; 3], n: usize) -> Vec<[f32; 3]> {
    let n = n.max(1);
    let chord = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
    let len = (chord[0].powi(2) + chord[1].powi(2) + chord[2].powi(2)).sqrt();
    if len <= 1e-6 {
        // A degenerate chord (self-edge / coincident nodes): a constant sample
        // run, same length as every other curve so the painter stays uniform.
        return vec![a; n + 1];
    }
    // The lift direction: perpendicular to the chord. chord × ŷ gives a lift in
    // the horizontal plane (the house look: the arc swings AROUND the cloud,
    // not up out of it); a near-vertical chord falls back to chord × x̂.
    let cross = |u: [f32; 3], v: [f32; 3]| [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]];
    let mut perp = cross(chord, [0.0, 1.0, 0.0]);
    let mut pl = (perp[0].powi(2) + perp[1].powi(2) + perp[2].powi(2)).sqrt();
    if pl <= 1e-5 {
        perp = cross(chord, [1.0, 0.0, 0.0]);
        pl = (perp[0].powi(2) + perp[1].powi(2) + perp[2].powi(2)).sqrt();
    }
    let lift = CURVE_BOW * len / pl.max(1e-9);
    // The midpoint, lifted perpendicular to the chord…
    let m = [0, 1, 2].map(|j| (a[j] + b[j]) * 0.5 + perp[j] * lift);
    // …and the quadratic-Bézier control that puts the curve THROUGH it at
    // t = 0.5: B(½) = ¼a + ½c + ¼b = m  ⇒  c = 2m − (a + b)/2.
    let c = [0, 1, 2].map(|j| 2.0 * m[j] - (a[j] + b[j]) * 0.5);
    (0..=n)
        .map(|i| {
            let t = i as f32 / n as f32;
            let s = 1.0 - t;
            [0, 1, 2].map(|j| s * s * a[j] + 2.0 * s * t * c[j] + t * t * b[j])
        })
        .collect()
}

/// Cumulative arc lengths of a 3D polyline: `(cum, total)` with
/// `cum.len() == pts.len()`, `cum[0] == 0`, `cum.last() == total`.
#[must_use]
pub fn arc_lengths(pts: &[[f32; 3]]) -> (Vec<f32>, f32) {
    let mut cum = Vec::with_capacity(pts.len());
    let mut total = 0.0f32;
    cum.push(0.0);
    for w in pts.windows(2) {
        let d = ((w[1][0] - w[0][0]).powi(2) + (w[1][1] - w[0][1]).powi(2) + (w[1][2] - w[0][2]).powi(2)).sqrt();
        total += d;
        cum.push(total);
    }
    (cum, total)
}

/// The 3D point at fraction `frac ∈ [0, 1]` of the polyline's **arc length**
/// (not its parameter) — the seam that makes a pulse glide at constant speed
/// through segments of unequal length.
#[must_use]
pub fn point_at(pts: &[[f32; 3]], cum: &[f32], total: f32, frac: f32) -> [f32; 3] {
    if pts.is_empty() {
        return [0.0; 3];
    }
    if pts.len() == 1 || total <= 0.0 {
        return pts[0];
    }
    let want = frac.clamp(0.0, 1.0) * total;
    let i = match cum.binary_search_by(|c| c.partial_cmp(&want).unwrap_or(std::cmp::Ordering::Less)) {
        Ok(i) => i,
        Err(i) => i.saturating_sub(1),
    }
    .min(pts.len() - 2);
    let seg = (cum[i + 1] - cum[i]).max(1e-9);
    let t = ((want - cum[i]) / seg).clamp(0.0, 1.0);
    [0, 1, 2].map(|j| pts[i][j] + (pts[i + 1][j] - pts[i][j]) * t)
}

/// The pulse speed (cycles/sec) a rate earns: linear between [`PULSE_MIN_HZ`]
/// and [`PULSE_MAX_HZ`] by the rate's share of the scene's maximum.
#[must_use]
pub fn pulse_speed_hz(rate: f32, max_rate: f32) -> f32 {
    let share = if max_rate > 0.0 { (rate / max_rate).clamp(0.0, 1.0) } else { 0.0 };
    PULSE_MIN_HZ + (PULSE_MAX_HZ - PULSE_MIN_HZ) * share
}

/// The fractional pulse phase at `clock_s` for a rate: `(clock · speed) mod 1`.
/// Advances continuously with the clock — the smooth-motion seam the tests pin:
/// the clock drives a FRACTIONAL phase every frame (f64 until the final mod, so
/// a long-running clock does not quantise the motion).
#[must_use]
pub fn pulse_phase(rate: f32, max_rate: f32, clock_s: f64) -> f32 {
    (clock_s * f64::from(pulse_speed_hz(rate, max_rate))).rem_euclid(1.0) as f32
}