Skip to main content

proof_engine/debug/
graph.rs

1//! In-world math function grapher.
2//!
3//! Renders a sampled graph of any `MathFunction` as a column of ASCII bar characters
4//! rendered as glyphs in the scene. Useful for live-tweaking math parameters.
5
6use crate::{MathFunction, Glyph, RenderLayer};
7use crate::glyph::GlyphId;
8use crate::ProofEngine;
9use glam::{Vec2, Vec3, Vec4};
10
11// Bar chars: 0% → space, 12% → ▁, ... → █
12const BAR_CHARS: &[char] = &[' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
13
14/// Renders a `MathFunction` as a real-time scrolling oscilloscope graph.
15pub struct MathGraph {
16    pub function:    MathFunction,
17    /// Time range to sample: (start_offset_behind_now, end_offset_ahead).
18    pub time_range:  (f32, f32),
19    pub columns:     usize,
20    pub rows:        usize,
21    pub graph_color: Vec4,
22    pub axis_color:  Vec4,
23    pub bg_color:    Vec4,
24    pub label:       Option<String>,
25}
26
27impl MathGraph {
28    pub fn new(function: MathFunction, time_range: (f32, f32)) -> Self {
29        Self {
30            function,
31            time_range,
32            columns: 24,
33            rows: 6,
34            graph_color: Vec4::new(0.3, 1.0, 0.5, 0.9),
35            axis_color:  Vec4::new(0.5, 0.5, 0.5, 0.6),
36            bg_color:    Vec4::new(0.05, 0.05, 0.1, 0.7),
37            label: None,
38        }
39    }
40
41    pub fn with_label(mut self, label: impl Into<String>) -> Self {
42        self.label = Some(label.into());
43        self
44    }
45
46    pub fn with_dimensions(mut self, columns: usize, rows: usize) -> Self {
47        self.columns = columns;
48        self.rows    = rows;
49        self
50    }
51
52    /// Sample the function and render as glyphs. Returns the spawned glyph IDs.
53    pub fn render(
54        &self,
55        engine:  &mut ProofEngine,
56        origin:  Vec3,
57        char_w:  f32,
58        char_h:  f32,
59        time:    f32,
60    ) -> Vec<GlyphId> {
61        let mut ids   = Vec::new();
62        let cols      = self.columns.max(4);
63        let rows      = self.rows.max(2);
64        let char_w_s  = char_w  / cols  as f32;
65        let char_h_s  = char_h  / rows  as f32;
66
67        // Sample the function over the time range
68        let t_start = time + self.time_range.0;
69        let t_end   = time + self.time_range.1;
70        let samples: Vec<f32> = (0..cols).map(|i| {
71            let t = t_start + (i as f32 / (cols - 1) as f32) * (t_end - t_start);
72            self.function.evaluate(t, 0.0)
73        }).collect();
74
75        // Find range for normalization
76        let s_min = samples.iter().cloned().fold(f32::MAX, f32::min);
77        let s_max = samples.iter().cloned().fold(f32::MIN, f32::max);
78        let range = (s_max - s_min).max(f32::EPSILON);
79
80        // Draw background grid (dots)
81        for row in 0..rows {
82            for col in 0..cols {
83                let pos = origin + Vec3::new(col as f32 * char_w_s, -(row as f32 * char_h_s), 0.0);
84                let is_mid_row = row == rows / 2;
85                let ch    = if is_mid_row { '─' } else { '·' };
86                let color = if is_mid_row { self.axis_color } else { self.bg_color };
87                let id = engine.scene.spawn_glyph(Glyph {
88                    character: ch,
89                    position:  pos,
90                    color,
91                    emission:  0.0,
92                    glow_color:  Vec3::ZERO,
93                    glow_radius: 0.0,
94                    scale:     Vec2::splat(char_w_s * 0.9),
95                    layer:     RenderLayer::UI,
96                    ..Default::default()
97                });
98                ids.push(id);
99            }
100        }
101
102        // Draw graph bars
103        for (col, &sample) in samples.iter().enumerate() {
104            let norm = ((sample - s_min) / range).clamp(0.0, 1.0);
105            let bar_height = (norm * rows as f32) as usize;
106
107            for row in 0..rows {
108                let row_flip = rows - 1 - row; // flip: row 0 = bottom
109                let pos = origin + Vec3::new(col as f32 * char_w_s, -(row as f32 * char_h_s), 0.1);
110
111                if row_flip < bar_height {
112                    // Fully filled cell
113                    let ch    = '█';
114                    let t     = row_flip as f32 / rows.max(1) as f32;
115                    let color = Vec4::new(
116                        self.graph_color.x * (0.5 + t * 0.5),
117                        self.graph_color.y,
118                        self.graph_color.z * (1.0 - t * 0.3),
119                        self.graph_color.w,
120                    );
121                    let id = engine.scene.spawn_glyph(Glyph {
122                        character: ch,
123                        position:  pos,
124                        color,
125                        emission:  0.4 + t * 0.4,
126                        glow_color:  Vec3::new(color.x, color.y, color.z),
127                        glow_radius: 0.5,
128                        scale:     Vec2::splat(char_w_s * 0.85),
129                        layer:     RenderLayer::UI,
130                        ..Default::default()
131                    });
132                    ids.push(id);
133                } else if row_flip == bar_height {
134                    // Partial cell — use fractional bar character
135                    let frac_height = (norm * rows as f32).fract();
136                    let bar_idx = (frac_height * BAR_CHARS.len() as f32) as usize;
137                    let ch = BAR_CHARS[bar_idx.min(BAR_CHARS.len() - 1)];
138                    let id = engine.scene.spawn_glyph(Glyph {
139                        character: ch,
140                        position:  pos,
141                        color:     self.graph_color,
142                        emission:  0.8,
143                        glow_color:  Vec3::new(self.graph_color.x, self.graph_color.y, self.graph_color.z),
144                        glow_radius: 0.8,
145                        scale:     Vec2::splat(char_w_s * 0.85),
146                        layer:     RenderLayer::UI,
147                        ..Default::default()
148                    });
149                    ids.push(id);
150                }
151            }
152        }
153
154        // Draw label below the graph
155        if let Some(ref label) = self.label {
156            for (i, ch) in label.chars().enumerate() {
157                let pos = origin + Vec3::new(
158                    i as f32 * char_w_s,
159                    -(rows as f32 * char_h_s + 0.3),
160                    0.1,
161                );
162                let id = engine.scene.spawn_glyph(Glyph {
163                    character: ch,
164                    position:  pos,
165                    color:     Vec4::new(0.7, 0.7, 0.7, 0.8),
166                    scale:     Vec2::splat(char_w_s * 0.8),
167                    layer:     RenderLayer::UI,
168                    ..Default::default()
169                });
170                ids.push(id);
171            }
172        }
173
174        // Draw current value as a floating label
175        let current = self.function.evaluate(time, 0.0);
176        let val_str = format!("{:+.2}", current);
177        for (i, ch) in val_str.chars().enumerate() {
178            let pos = origin + Vec3::new(
179                (cols as f32 + 0.5 + i as f32) * char_w_s,
180                -(rows as f32 * 0.5 * char_h_s),
181                0.1,
182            );
183            let id = engine.scene.spawn_glyph(Glyph {
184                character: ch,
185                position:  pos,
186                color:     self.graph_color,
187                emission:  0.5,
188                glow_color: Vec3::new(self.graph_color.x, self.graph_color.y, self.graph_color.z),
189                scale:     Vec2::splat(char_w_s),
190                layer:     RenderLayer::UI,
191                ..Default::default()
192            });
193            ids.push(id);
194        }
195
196        ids
197    }
198}