Skip to main content

agent/orbs/engine/
core.rs

1//! Shared primitives for the dotted 3D thought-orbs.
2//! Ported from thinking-orbs / inkform (HalftoneSphere lineage).
3
4use std::{cell::RefCell, f32::consts::PI};
5
6type Vec3 = [f32; 3];
7type Vec2 = [f32; 2];
8
9thread_local! {
10    /// Per-thread topology caches, indexed directly by sample count. Public
11    /// draw inputs are capped before reaching these helpers, so the sparse
12    /// vectors stay small while lookup remains O(1).
13    static FIBONACCI_DIRECTIONS: RefCell<Vec<Option<Box<[Vec3]>>>> = const { RefCell::new(Vec::new()) };
14    static UNIT_CIRCLES: RefCell<Vec<Option<Box<[Vec2]>>>> = const { RefCell::new(Vec::new()) };
15}
16
17/// A projected, depth-shaded disk.
18#[derive(Clone, Copy, Debug)]
19pub struct Dot {
20    pub x: f32,
21    pub y: f32,
22    pub z: f32,
23    pub r: f32,
24    /// Ink value: 0 = darkest ink on paper. Mirrored on dark themes.
25    pub white: f32,
26    pub a: f32,
27}
28
29impl Dot {
30    pub fn new(x: f32, y: f32, z: f32, r: f32, white: f32) -> Self {
31        Self {
32            x,
33            y,
34            z,
35            r,
36            white,
37            a: 1.0,
38        }
39    }
40
41    pub fn with_a(mut self, a: f32) -> Self {
42        self.a = a;
43        self
44    }
45}
46
47/// A stroked edge between two projected points (the `connecting` web).
48#[derive(Clone, Copy, Debug)]
49pub struct Line {
50    pub x1: f32,
51    pub y1: f32,
52    pub x2: f32,
53    pub y2: f32,
54    pub white: f32,
55    pub a: f32,
56    pub w: f32,
57}
58
59/// Frame geometry produced by a mode painter (backend-agnostic).
60#[derive(Clone, Debug, Default)]
61pub struct Frame {
62    pub dots: Vec<Dot>,
63    pub lines: Vec<Line>,
64}
65
66impl Frame {
67    pub fn new() -> Self {
68        Self::default()
69    }
70
71    /// Drop all geometry but keep the allocated capacity, so a steady-state
72    /// animation loop reuses one pair of buffers forever.
73    #[inline]
74    pub fn clear(&mut self) {
75        self.dots.clear();
76        self.lines.clear();
77    }
78}
79
80/// Spin + tilt + orthographic projection, precomputed once per frame.
81///
82/// This used to be a `Box<dyn Fn>`, which meant a heap allocation on every
83/// `draw_mode` call and an indirect call per dot. As a plain struct it is
84/// stack-allocated and `project` inlines into the dot loops.
85#[derive(Clone, Copy, Debug)]
86pub struct Proj {
87    st: f32,
88    ct: f32,
89    sy: f32,
90    cyw: f32,
91    cx: f32,
92    cy: f32,
93    scale: f32,
94}
95
96impl Proj {
97    #[inline]
98    pub fn project(&self, x: f32, y: f32, z: f32) -> (f32, f32, f32) {
99        let x1 = x * self.cyw + z * self.sy;
100        let z1 = -x * self.sy + z * self.cyw;
101        let y1 = y * self.ct - z1 * self.st;
102        let z2 = y * self.st + z1 * self.ct;
103        (self.cx + x1 * self.scale, self.cy - y1 * self.scale, z2)
104    }
105}
106
107#[inline]
108pub fn lerp(a: f32, b: f32, f: f32) -> f32 {
109    a + (b - a) * f
110}
111
112#[inline]
113pub fn frac(x: f32) -> f32 {
114    x - x.floor()
115}
116
117/// Deterministic hash in [0, 1).
118///
119/// The magic numbers are the canonical GLSL `fract(sin(dot(...)) * 43758.5453)`
120/// constants, kept verbatim from upstream so the generated dot layouts match.
121/// They carry more digits than `f32` can hold; that is intentional — they are
122/// quoted as written, and truncating them would be a gratuitous divergence.
123#[inline]
124#[allow(clippy::excessive_precision)]
125pub fn hash_d(a: f32, b: f32) -> f32 {
126    let h = (a * 12.9898 + b * 78.233).sin() * 43758.5453;
127    h - h.floor()
128}
129
130/// Value noise on a 2D lattice — smooth, deterministic, cheap.
131pub fn vnoise(x: f32, y: f32) -> f32 {
132    let xi = x.floor();
133    let yi = y.floor();
134    let mut fx = x - xi;
135    let mut fy = y - yi;
136    fx = fx * fx * (3.0 - 2.0 * fx);
137    fy = fy * fy * (3.0 - 2.0 * fy);
138    let a = hash_d(xi, yi);
139    let b = hash_d(xi + 1.0, yi);
140    let c = hash_d(xi, yi + 1.0);
141    let d = hash_d(xi + 1.0, yi + 1.0);
142    a + (b - a) * fx + (c - a) * fy + (a - b - c + d) * fx * fy
143}
144
145/// Stable directions on a unit sphere (Fibonacci lattice).
146pub fn fib_dir(i: usize, n: usize) -> (f32, f32, f32) {
147    let golden = PI * (3.0 - 5.0_f32.sqrt());
148    let y = 1.0 - (2.0 * (i as f32 + 0.5)) / n as f32;
149    let rad = (1.0 - y * y).sqrt();
150    let a = i as f32 * golden;
151    (rad * a.cos(), y, rad * a.sin())
152}
153
154/// Borrow the stable Fibonacci sphere for `n` points from a thread-local cache.
155/// Profiles reuse the same handful of sanitized counts every frame, so their
156/// square roots and trigonometry are normally paid only once.
157pub fn with_fib_dirs<R>(n: usize, f: impl FnOnce(&[Vec3]) -> R) -> R {
158    FIBONACCI_DIRECTIONS.with(|cell| {
159        let mut cache = cell.borrow_mut();
160        if cache.len() <= n {
161            cache.resize_with(n + 1, || None);
162        }
163        let directions = cache[n].get_or_insert_with(|| {
164            (0..n)
165                .map(|i| {
166                    let (x, y, z) = fib_dir(i, n);
167                    [x, y, z]
168                })
169                .collect::<Vec<_>>()
170                .into_boxed_slice()
171        });
172        f(directions)
173    })
174}
175
176/// Borrow evenly spaced `(cos θ, sin θ)` samples for a closed circle.
177/// Circle topology occurs in almost every mode and is invariant across frames.
178pub fn with_unit_circle<R>(n: usize, f: impl FnOnce(&[Vec2]) -> R) -> R {
179    UNIT_CIRCLES.with(|cell| {
180        let mut cache = cell.borrow_mut();
181        if cache.len() <= n {
182            cache.resize_with(n + 1, || None);
183        }
184        let circle = cache[n].get_or_insert_with(|| {
185            let step = 2.0 * PI / n.max(1) as f32;
186            (0..n)
187                .map(|i| {
188                    let a = i as f32 * step;
189                    [a.cos(), a.sin()]
190                })
191                .collect::<Vec<_>>()
192                .into_boxed_slice()
193        });
194        f(circle)
195    })
196}
197
198/// Shortest signed angular distance, wrapped to (-π, π].
199#[inline]
200pub fn angle_delta(a: f32, b: f32) -> f32 {
201    (a - b).sin().atan2((a - b).cos())
202}
203
204/// Shared spin + tilt + orthographic projection.
205pub fn make_proj(yaw: f32, tilt: f32, cx: f32, cy: f32, scale: f32) -> Proj {
206    Proj {
207        st: tilt.sin(),
208        ct: tilt.cos(),
209        sy: yaw.sin(),
210        cyw: yaw.cos(),
211        cx,
212        cy,
213        scale,
214    }
215}
216
217/// Dot radii were tuned for a 300pt frame; sub-linear scaling keeps small
218/// spinners legible. Lower pow = radii shrink less with size.
219#[inline]
220pub fn radius_scale(size: f32, pow: f32) -> f32 {
221    (size / 300.0).powf(pow)
222}
223
224/// Z-sort far→near so nearer dots paint over farther ones.
225///
226/// `sort_unstable_by` is used deliberately: it sorts in place (the stable sort
227/// allocates a scratch buffer every call) and ties only happen between dots at
228/// identical depth, where paint order is visually irrelevant.
229pub fn sort_dots(dots: &mut [Dot]) {
230    dots.sort_unstable_by(|a, b| a.z.partial_cmp(&b.z).unwrap_or(std::cmp::Ordering::Equal));
231}