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
}