#![allow(dead_code)]
use rayon::prelude::*;
#[derive(Debug, Clone)]
pub struct FieldConfig {
pub grid_n: usize,
pub u_range: [f64; 2],
pub v_range: [f64; 2],
pub fate_steps: usize,
pub fate_dt: f64,
pub escape_radius: f64,
pub loop_epsilon: f64,
pub arrow_length: f32,
pub arrow_alpha: f32,
}
impl Default for FieldConfig {
fn default() -> Self {
Self {
grid_n: 40,
u_range: [-std::f64::consts::PI, std::f64::consts::PI],
v_range: [-std::f64::consts::PI, std::f64::consts::PI],
fate_steps: 200,
fate_dt: 0.04,
escape_radius: 8.0,
loop_epsilon: 0.3,
arrow_length: 0.04,
arrow_alpha: 0.7,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GeodesicFate {
Bounded,
Escaping,
Looping,
}
impl GeodesicFate {
pub fn color(self, alpha: f32) -> [f32; 4] {
match self {
GeodesicFate::Bounded => [0.2, 0.5, 1.0, alpha],
GeodesicFate::Escaping => [1.0, 0.25, 0.15, alpha],
GeodesicFate::Looping => [0.2, 0.9, 0.35, alpha],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[repr(C)]
pub struct FlowArrow {
pub origin: [f32; 2],
pub direction: [f32; 2],
pub length: f32,
pub color: [f32; 4],
pub fate: u32,
}
unsafe impl bytemuck::Pod for FlowArrow {}
unsafe impl bytemuck::Zeroable for FlowArrow {}
impl FlowArrow {
pub fn new(
origin: [f32; 2],
direction: [f32; 2],
length: f32,
fate: GeodesicFate,
alpha: f32,
) -> Self {
let fate_int = match fate {
GeodesicFate::Bounded => 0,
GeodesicFate::Escaping => 1,
GeodesicFate::Looping => 2,
};
Self {
origin,
direction,
length,
color: fate.color(alpha),
fate: fate_int,
}
}
}
pub struct GeodesicField {
pub config: FieldConfig,
pub arrows: Vec<FlowArrow>,
pub fate_counts: [usize; 3],
}
impl GeodesicField {
pub fn fate_fractions(&self) -> [f32; 3] {
let total = self.arrows.len().max(1) as f32;
[
self.fate_counts[0] as f32 / total,
self.fate_counts[1] as f32 / total,
self.fate_counts[2] as f32 / total,
]
}
}
pub struct FieldRenderer {
config: FieldConfig,
}
impl FieldRenderer {
pub fn new(config: FieldConfig) -> Self {
Self { config }
}
pub fn compute<F>(&self, step_fn: F) -> GeodesicField
where
F: Fn(f64, f64, f64, f64) -> (f64, f64, f64, f64) + Send + Sync,
{
let n = self.config.grid_n;
let [u_min, u_max] = self.config.u_range;
let [v_min, v_max] = self.config.v_range;
let du_param = (u_max - u_min) / n as f64;
let dv_param = (v_max - v_min) / n as f64;
let cfg = &self.config;
let cells: Vec<(usize, usize)> = (0..n).flat_map(|i| (0..n).map(move |j| (i, j))).collect();
let arrows: Vec<FlowArrow> = cells
.par_iter()
.map(|&(i, j)| {
let u0 = u_min + (i as f64 + 0.5) * du_param;
let v0 = v_min + (j as f64 + 0.5) * dv_param;
let angle = 2.0 * std::f64::consts::PI * (i * n + j) as f64 / (n * n) as f64;
let du_init = angle.cos();
let dv_init = angle.sin();
let (fate, final_u, final_v) =
classify_fate(u0, v0, du_init, dv_init, cfg, &step_fn);
let raw_dir = [(final_u - u0) as f32, (final_v - v0) as f32];
let dir = normalise2(raw_dir);
FlowArrow::new(
[u0 as f32, v0 as f32],
dir,
cfg.arrow_length,
fate,
cfg.arrow_alpha,
)
})
.collect();
let mut fate_counts = [0usize; 3];
for arrow in &arrows {
fate_counts[arrow.fate as usize % 3] += 1;
}
GeodesicField {
config: cfg.clone(),
arrows,
fate_counts,
}
}
}
fn classify_fate<F>(
u0: f64,
v0: f64,
du0: f64,
dv0: f64,
cfg: &FieldConfig,
step_fn: &F,
) -> (GeodesicFate, f64, f64)
where
F: Fn(f64, f64, f64, f64) -> (f64, f64, f64, f64),
{
let escape_sq = cfg.escape_radius * cfg.escape_radius;
let loop_sq = cfg.loop_epsilon * cfg.loop_epsilon;
let mut u = u0;
let mut v = v0;
let mut du = du0;
let mut dv = dv0;
for _ in 0..cfg.fate_steps {
let (nu, nv, ndu, ndv) = step_fn(u, v, du, dv);
u = nu;
v = nv;
du = ndu;
dv = ndv;
let dist_sq_from_origin = u * u + v * v;
if dist_sq_from_origin > escape_sq {
return (GeodesicFate::Escaping, u, v);
}
let return_sq = (u - u0) * (u - u0) + (v - v0) * (v - v0);
if return_sq < loop_sq {
return (GeodesicFate::Looping, u, v);
}
}
(GeodesicFate::Bounded, u, v)
}
fn normalise2(v: [f32; 2]) -> [f32; 2] {
let mag = (v[0] * v[0] + v[1] * v[1]).sqrt();
if mag < 1e-12 {
[1.0, 0.0]
} else {
[v[0] / mag, v[1] / mag]
}
}
#[cfg(test)]
mod tests {
use super::*;
fn identity_step(u: f64, v: f64, du: f64, dv: f64) -> (f64, f64, f64, f64) {
let dt = 0.04;
(u + du * dt, v + dv * dt, du, dv)
}
#[test]
fn renderer_produces_correct_count() {
let cfg = FieldConfig {
grid_n: 5,
fate_steps: 10,
..Default::default()
};
let renderer = FieldRenderer::new(cfg);
let field = renderer.compute(identity_step);
assert_eq!(field.arrows.len(), 25, "5×5 grid should yield 25 arrows");
}
#[test]
fn fate_fractions_sum_to_one() {
let cfg = FieldConfig {
grid_n: 4,
fate_steps: 10,
..Default::default()
};
let renderer = FieldRenderer::new(cfg);
let field = renderer.compute(identity_step);
let fracs = field.fate_fractions();
let sum: f32 = fracs.iter().sum();
assert!(
(sum - 1.0).abs() < 1e-5,
"fate fractions should sum to 1, got {sum}"
);
}
#[test]
fn escape_detected_for_fast_step() {
let fast_step =
|_u: f64, _v: f64, _du: f64, _dv: f64| (1000.0_f64, 1000.0_f64, 0.0_f64, 0.0_f64);
let cfg = FieldConfig {
grid_n: 2,
fate_steps: 5,
escape_radius: 10.0,
..Default::default()
};
let renderer = FieldRenderer::new(cfg);
let field = renderer.compute(fast_step);
assert!(
field.fate_counts[1] == 4,
"all 4 cells should escape, got {:?}",
field.fate_counts
);
}
#[test]
fn normalise2_unit_vector() {
let n = normalise2([3.0, 4.0]);
let mag = (n[0] * n[0] + n[1] * n[1]).sqrt();
assert!((mag - 1.0).abs() < 1e-6, "normalised magnitude should be 1");
}
#[test]
fn normalise2_zero_vector_fallback() {
let n = normalise2([0.0, 0.0]);
assert_eq!(n, [1.0, 0.0]);
}
#[test]
fn flow_arrow_fate_integer_mapping() {
let a = FlowArrow::new([0.0, 0.0], [1.0, 0.0], 1.0, GeodesicFate::Bounded, 1.0);
assert_eq!(a.fate, 0);
let b = FlowArrow::new([0.0, 0.0], [1.0, 0.0], 1.0, GeodesicFate::Escaping, 1.0);
assert_eq!(b.fate, 1);
let c = FlowArrow::new([0.0, 0.0], [1.0, 0.0], 1.0, GeodesicFate::Looping, 1.0);
assert_eq!(c.fate, 2);
}
#[test]
fn geodesic_fate_colors_in_range() {
for fate in [
GeodesicFate::Bounded,
GeodesicFate::Escaping,
GeodesicFate::Looping,
] {
let c = fate.color(0.8);
for ch in c.iter() {
assert!(*ch >= 0.0 && *ch <= 1.0, "color channel {ch} out of range");
}
}
}
}