use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rgb {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl Rgb {
#[must_use]
pub const fn new(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b }
}
pub const NAN_COLOR: Self = Self::new(255, 0, 255);
pub const INF_COLOR: Self = Self::new(255, 255, 255);
pub const NEG_INF_COLOR: Self = Self::new(0, 0, 0);
}
#[derive(Debug, Clone)]
pub struct ColorPalette {
pub(crate) colors: Vec<Rgb>,
}
impl Default for ColorPalette {
fn default() -> Self {
Self::viridis()
}
}
impl ColorPalette {
#[must_use]
pub fn viridis() -> Self {
Self {
colors: vec![
Rgb::new(68, 1, 84),
Rgb::new(59, 82, 139),
Rgb::new(33, 145, 140),
Rgb::new(94, 201, 98),
Rgb::new(253, 231, 37),
],
}
}
#[must_use]
pub fn grayscale() -> Self {
Self { colors: vec![Rgb::new(0, 0, 0), Rgb::new(128, 128, 128), Rgb::new(255, 255, 255)] }
}
#[must_use]
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
pub fn interpolate(&self, t: f32) -> Rgb {
let t = t.clamp(0.0, 1.0);
let n = self.colors.len() - 1;
let idx = (t * n as f32).floor() as usize;
let idx = idx.min(n - 1);
let local_t = t * n as f32 - idx as f32;
let c1 = &self.colors[idx];
let c2 = &self.colors[idx + 1];
Rgb {
r: (c1.r as f32 + (c2.r as f32 - c1.r as f32) * local_t) as u8,
g: (c1.g as f32 + (c2.g as f32 - c1.g as f32) * local_t) as u8,
b: (c1.b as f32 + (c2.b as f32 - c1.b as f32) * local_t) as u8,
}
}
}
#[derive(Debug, Clone)]
pub struct VisualRegressionConfig {
pub golden_dir: PathBuf,
pub output_dir: PathBuf,
pub max_diff_pct: f64,
pub palette: ColorPalette,
}
impl Default for VisualRegressionConfig {
fn default() -> Self {
Self {
golden_dir: PathBuf::from("golden"),
output_dir: PathBuf::from("test_output"),
max_diff_pct: 0.0, palette: ColorPalette::default(),
}
}
}
impl VisualRegressionConfig {
#[must_use]
pub fn new(golden_dir: impl Into<PathBuf>) -> Self {
Self { golden_dir: golden_dir.into(), ..Default::default() }
}
#[must_use]
pub fn with_output_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.output_dir = dir.into();
self
}
#[must_use]
pub const fn with_max_diff_pct(mut self, pct: f64) -> Self {
self.max_diff_pct = pct;
self
}
#[must_use]
pub fn with_palette(mut self, palette: ColorPalette) -> Self {
self.palette = palette;
self
}
}
#[derive(Debug, Clone)]
pub struct PixelDiffResult {
pub different_pixels: usize,
pub total_pixels: usize,
pub max_diff: u32,
}
impl PixelDiffResult {
#[must_use]
pub fn diff_percentage(&self) -> f64 {
if self.total_pixels == 0 {
0.0
} else {
(self.different_pixels as f64 / self.total_pixels as f64) * 100.0
}
}
#[must_use]
pub fn matches(&self, threshold_pct: f64) -> bool {
self.diff_percentage() <= threshold_pct
}
#[must_use]
pub const fn pass(total_pixels: usize) -> Self {
Self { different_pixels: 0, total_pixels, max_diff: 0 }
}
}
#[derive(Debug, Clone)]
pub struct BufferRenderer {
palette: ColorPalette,
pub(crate) range: Option<(f32, f32)>,
}
impl Default for BufferRenderer {
fn default() -> Self {
Self::new()
}
}
impl BufferRenderer {
#[must_use]
pub fn new() -> Self {
Self { palette: ColorPalette::default(), range: None }
}
#[must_use]
pub const fn with_range(mut self, min: f32, max: f32) -> Self {
self.range = Some((min, max));
self
}
#[must_use]
pub fn with_palette(mut self, palette: ColorPalette) -> Self {
self.palette = palette;
self
}
#[must_use]
pub fn render_to_rgba(&self, buffer: &[f32], width: u32, height: u32) -> Vec<u8> {
assert_eq!(buffer.len(), (width * height) as usize);
let (min_val, max_val) = self.range.unwrap_or_else(|| {
let valid: Vec<f32> = buffer.iter().copied().filter(|v| v.is_finite()).collect();
if valid.is_empty() {
(0.0, 1.0)
} else {
let min = valid.iter().copied().fold(f32::INFINITY, f32::min);
let max = valid.iter().copied().fold(f32::NEG_INFINITY, f32::max);
(min, max.max(min + f32::EPSILON))
}
});
let mut rgba = Vec::with_capacity(buffer.len() * 4);
for &value in buffer {
let color = if value.is_nan() {
Rgb::NAN_COLOR
} else if value.is_infinite() {
if value > 0.0 {
Rgb::INF_COLOR
} else {
Rgb::NEG_INF_COLOR
}
} else {
let t = (value - min_val) / (max_val - min_val);
self.palette.interpolate(t)
};
rgba.push(color.r);
rgba.push(color.g);
rgba.push(color.b);
rgba.push(255); }
rgba
}
#[must_use]
pub fn compare_rgba(&self, a: &[u8], b: &[u8], tolerance: u8) -> PixelDiffResult {
if a == b {
return PixelDiffResult::pass(a.len() / 4);
}
let min_len = a.len().min(b.len());
let mut different = 0;
let mut max_diff: u32 = 0;
for i in (0..min_len).step_by(4) {
let mut pixel_diff = false;
for j in 0..4 {
if i + j < min_len {
let diff = (a[i + j] as i32 - b[i + j] as i32).unsigned_abs();
if diff > tolerance as u32 {
pixel_diff = true;
max_diff = max_diff.max(diff);
}
}
}
if pixel_diff {
different += 1;
}
}
if a.len() != b.len() {
different += a.len().abs_diff(b.len()) / 4;
}
PixelDiffResult {
different_pixels: different,
total_pixels: min_len.max(a.len()).max(b.len()) / 4,
max_diff,
}
}
}
#[derive(Debug, Clone)]
pub struct GoldenBaseline {
config: VisualRegressionConfig,
}
impl GoldenBaseline {
#[must_use]
pub fn new(config: VisualRegressionConfig) -> Self {
Self { config }
}
#[must_use]
pub fn golden_path(&self, name: &str) -> PathBuf {
self.config.golden_dir.join(format!("{name}.golden"))
}
#[must_use]
pub fn output_path(&self, name: &str) -> PathBuf {
self.config.output_dir.join(format!("{name}.output"))
}
#[must_use]
pub const fn config(&self) -> &VisualRegressionConfig {
&self.config
}
}
#[cfg(test)]
mod tests;