trueno/simulation/visual/
mod.rs1use std::path::PathBuf;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub struct Rgb {
11 pub r: u8,
13 pub g: u8,
15 pub b: u8,
17}
18
19impl Rgb {
20 #[must_use]
22 pub const fn new(r: u8, g: u8, b: u8) -> Self {
23 Self { r, g, b }
24 }
25
26 pub const NAN_COLOR: Self = Self::new(255, 0, 255);
28 pub const INF_COLOR: Self = Self::new(255, 255, 255);
30 pub const NEG_INF_COLOR: Self = Self::new(0, 0, 0);
32}
33
34#[derive(Debug, Clone)]
36pub struct ColorPalette {
37 pub(crate) colors: Vec<Rgb>,
38}
39
40impl Default for ColorPalette {
41 fn default() -> Self {
42 Self::viridis()
43 }
44}
45
46impl ColorPalette {
47 #[must_use]
49 pub fn viridis() -> Self {
50 Self {
51 colors: vec![
52 Rgb::new(68, 1, 84),
53 Rgb::new(59, 82, 139),
54 Rgb::new(33, 145, 140),
55 Rgb::new(94, 201, 98),
56 Rgb::new(253, 231, 37),
57 ],
58 }
59 }
60
61 #[must_use]
63 pub fn grayscale() -> Self {
64 Self { colors: vec![Rgb::new(0, 0, 0), Rgb::new(128, 128, 128), Rgb::new(255, 255, 255)] }
65 }
66
67 #[must_use]
69 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
70 pub fn interpolate(&self, t: f32) -> Rgb {
71 let t = t.clamp(0.0, 1.0);
72 let n = self.colors.len() - 1;
73 let idx = (t * n as f32).floor() as usize;
74 let idx = idx.min(n - 1);
75 let local_t = t * n as f32 - idx as f32;
76
77 let c1 = &self.colors[idx];
78 let c2 = &self.colors[idx + 1];
79
80 Rgb {
81 r: (c1.r as f32 + (c2.r as f32 - c1.r as f32) * local_t) as u8,
82 g: (c1.g as f32 + (c2.g as f32 - c1.g as f32) * local_t) as u8,
83 b: (c1.b as f32 + (c2.b as f32 - c1.b as f32) * local_t) as u8,
84 }
85 }
86}
87
88#[derive(Debug, Clone)]
90pub struct VisualRegressionConfig {
91 pub golden_dir: PathBuf,
93 pub output_dir: PathBuf,
95 pub max_diff_pct: f64,
97 pub palette: ColorPalette,
99}
100
101impl Default for VisualRegressionConfig {
102 fn default() -> Self {
103 Self {
104 golden_dir: PathBuf::from("golden"),
105 output_dir: PathBuf::from("test_output"),
106 max_diff_pct: 0.0, palette: ColorPalette::default(),
108 }
109 }
110}
111
112impl VisualRegressionConfig {
113 #[must_use]
115 pub fn new(golden_dir: impl Into<PathBuf>) -> Self {
116 Self { golden_dir: golden_dir.into(), ..Default::default() }
117 }
118
119 #[must_use]
121 pub fn with_output_dir(mut self, dir: impl Into<PathBuf>) -> Self {
122 self.output_dir = dir.into();
123 self
124 }
125
126 #[must_use]
128 pub const fn with_max_diff_pct(mut self, pct: f64) -> Self {
129 self.max_diff_pct = pct;
130 self
131 }
132
133 #[must_use]
135 pub fn with_palette(mut self, palette: ColorPalette) -> Self {
136 self.palette = palette;
137 self
138 }
139}
140
141#[derive(Debug, Clone)]
143pub struct PixelDiffResult {
144 pub different_pixels: usize,
146 pub total_pixels: usize,
148 pub max_diff: u32,
150}
151
152impl PixelDiffResult {
153 #[must_use]
155 pub fn diff_percentage(&self) -> f64 {
156 if self.total_pixels == 0 {
157 0.0
158 } else {
159 (self.different_pixels as f64 / self.total_pixels as f64) * 100.0
160 }
161 }
162
163 #[must_use]
165 pub fn matches(&self, threshold_pct: f64) -> bool {
166 self.diff_percentage() <= threshold_pct
167 }
168
169 #[must_use]
171 pub const fn pass(total_pixels: usize) -> Self {
172 Self { different_pixels: 0, total_pixels, max_diff: 0 }
173 }
174}
175
176#[derive(Debug, Clone)]
180pub struct BufferRenderer {
181 palette: ColorPalette,
182 pub(crate) range: Option<(f32, f32)>,
183}
184
185impl Default for BufferRenderer {
186 fn default() -> Self {
187 Self::new()
188 }
189}
190
191impl BufferRenderer {
192 #[must_use]
194 pub fn new() -> Self {
195 Self { palette: ColorPalette::default(), range: None }
196 }
197
198 #[must_use]
200 pub const fn with_range(mut self, min: f32, max: f32) -> Self {
201 self.range = Some((min, max));
202 self
203 }
204
205 #[must_use]
207 pub fn with_palette(mut self, palette: ColorPalette) -> Self {
208 self.palette = palette;
209 self
210 }
211
212 #[must_use]
216 pub fn render_to_rgba(&self, buffer: &[f32], width: u32, height: u32) -> Vec<u8> {
217 assert_eq!(buffer.len(), (width * height) as usize);
218
219 let (min_val, max_val) = self.range.unwrap_or_else(|| {
220 let valid: Vec<f32> = buffer.iter().copied().filter(|v| v.is_finite()).collect();
221 if valid.is_empty() {
222 (0.0, 1.0)
223 } else {
224 let min = valid.iter().copied().fold(f32::INFINITY, f32::min);
225 let max = valid.iter().copied().fold(f32::NEG_INFINITY, f32::max);
226 (min, max.max(min + f32::EPSILON))
227 }
228 });
229
230 let mut rgba = Vec::with_capacity(buffer.len() * 4);
231
232 for &value in buffer {
233 let color = if value.is_nan() {
234 Rgb::NAN_COLOR
235 } else if value.is_infinite() {
236 if value > 0.0 {
237 Rgb::INF_COLOR
238 } else {
239 Rgb::NEG_INF_COLOR
240 }
241 } else {
242 let t = (value - min_val) / (max_val - min_val);
243 self.palette.interpolate(t)
244 };
245
246 rgba.push(color.r);
247 rgba.push(color.g);
248 rgba.push(color.b);
249 rgba.push(255); }
251
252 rgba
253 }
254
255 #[must_use]
257 pub fn compare_rgba(&self, a: &[u8], b: &[u8], tolerance: u8) -> PixelDiffResult {
258 if a == b {
259 return PixelDiffResult::pass(a.len() / 4);
260 }
261
262 let min_len = a.len().min(b.len());
263 let mut different = 0;
264 let mut max_diff: u32 = 0;
265
266 for i in (0..min_len).step_by(4) {
268 let mut pixel_diff = false;
269 for j in 0..4 {
270 if i + j < min_len {
271 let diff = (a[i + j] as i32 - b[i + j] as i32).unsigned_abs();
272 if diff > tolerance as u32 {
273 pixel_diff = true;
274 max_diff = max_diff.max(diff);
275 }
276 }
277 }
278 if pixel_diff {
279 different += 1;
280 }
281 }
282
283 if a.len() != b.len() {
285 different += a.len().abs_diff(b.len()) / 4;
286 }
287
288 PixelDiffResult {
289 different_pixels: different,
290 total_pixels: min_len.max(a.len()).max(b.len()) / 4,
291 max_diff,
292 }
293 }
294}
295
296#[derive(Debug, Clone)]
298pub struct GoldenBaseline {
299 config: VisualRegressionConfig,
300}
301
302impl GoldenBaseline {
303 #[must_use]
305 pub fn new(config: VisualRegressionConfig) -> Self {
306 Self { config }
307 }
308
309 #[must_use]
311 pub fn golden_path(&self, name: &str) -> PathBuf {
312 self.config.golden_dir.join(format!("{name}.golden"))
313 }
314
315 #[must_use]
317 pub fn output_path(&self, name: &str) -> PathBuf {
318 self.config.output_dir.join(format!("{name}.output"))
319 }
320
321 #[must_use]
323 pub const fn config(&self) -> &VisualRegressionConfig {
324 &self.config
325 }
326}
327
328#[cfg(test)]
329mod tests;