molx 0.1.0

Interactive protein structure exploration in the terminal
Documentation
use font8x8::{BASIC_FONTS, UnicodeFonts};

pub type Color = [u8; 4];

pub struct Canvas {
    pub width: u32,
    pub height: u32,
    pub pixels: Vec<u8>,
    depth: Vec<f32>,
}

impl Canvas {
    pub fn new(width: u32, height: u32, background: Color) -> Self {
        let mut pixels = vec![0; width as usize * height as usize * 4];
        for pixel in pixels.chunks_exact_mut(4) {
            pixel.copy_from_slice(&background);
        }
        Self {
            width,
            height,
            pixels,
            depth: vec![f32::NEG_INFINITY; width as usize * height as usize],
        }
    }

    pub fn vertical_gradient(&mut self, top: Color, bottom: Color) {
        let denominator = self.height.saturating_sub(1).max(1) as f32;
        for y in 0..self.height {
            let amount = y as f32 / denominator;
            let color = mix(top, bottom, amount);
            let start = y as usize * self.width as usize * 4;
            let end = start + self.width as usize * 4;
            for pixel in self.pixels[start..end].chunks_exact_mut(4) {
                pixel.copy_from_slice(&color);
            }
        }
    }

    pub fn line_3d(
        &mut self,
        from: [f32; 3],
        to: [f32; 3],
        thickness: f32,
        from_color: Color,
        to_color: Color,
    ) {
        let dx = to[0] - from[0];
        let dy = to[1] - from[1];
        let distance = (dx * dx + dy * dy).sqrt();
        let steps = distance.ceil().max(1.0) as usize;
        for step in 0..=steps {
            let amount = step as f32 / steps as f32;
            let x = from[0] + dx * amount;
            let y = from[1] + dy * amount;
            let z = from[2] + (to[2] - from[2]) * amount;
            self.disc_3d(x, y, z, thickness * 0.5, mix(from_color, to_color, amount));
        }
    }

    pub fn sphere(&mut self, x: f32, y: f32, z: f32, radius: f32, color: Color) {
        let radius = radius.max(1.0);
        let min_x = (x - radius).floor() as i32;
        let max_x = (x + radius).ceil() as i32;
        let min_y = (y - radius).floor() as i32;
        let max_y = (y + radius).ceil() as i32;
        let light = [-0.42_f32, -0.55_f32, 0.72_f32];
        for py in min_y..=max_y {
            for px in min_x..=max_x {
                let nx = (px as f32 + 0.5 - x) / radius;
                let ny = (py as f32 + 0.5 - y) / radius;
                let radial = nx * nx + ny * ny;
                if radial > 1.0 {
                    continue;
                }
                let nz = (1.0 - radial).sqrt();
                let surface_z = z + nz * radius * 0.0025;
                let diffuse = (nx * light[0] + ny * light[1] + nz * light[2]).max(0.0);
                let rim = (1.0 - nz).powi(2) * 0.12;
                let shade = (0.48 + diffuse * 0.56 - rim).clamp(0.28, 1.12);
                self.pixel_3d(px, py, surface_z, scale(color, shade));
            }
        }
    }

    pub fn disc_3d(&mut self, x: f32, y: f32, z: f32, radius: f32, color: Color) {
        let radius = radius.max(0.8);
        let min_x = (x - radius).floor() as i32;
        let max_x = (x + radius).ceil() as i32;
        let min_y = (y - radius).floor() as i32;
        let max_y = (y + radius).ceil() as i32;
        let squared = radius * radius;
        for py in min_y..=max_y {
            for px in min_x..=max_x {
                let dx = px as f32 + 0.5 - x;
                let dy = py as f32 + 0.5 - y;
                if dx * dx + dy * dy <= squared {
                    self.pixel_3d(px, py, z, color);
                }
            }
        }
    }

    pub fn fill_rect(&mut self, x: u32, y: u32, width: u32, height: u32, color: Color) {
        for py in y..y.saturating_add(height).min(self.height) {
            for px in x..x.saturating_add(width).min(self.width) {
                self.pixel(px as i32, py as i32, color);
            }
        }
    }

    pub fn horizontal_line(&mut self, y: u32, color: Color) {
        if y >= self.height {
            return;
        }
        for x in 0..self.width {
            self.pixel(x as i32, y as i32, color);
        }
    }

    pub fn text(&mut self, x: u32, y: u32, value: &str, color: Color) {
        let mut cursor = x;
        for character in value.chars() {
            if cursor + 8 >= self.width {
                break;
            }
            if let Some(glyph) = BASIC_FONTS.get(character) {
                for (row, bits) in glyph.iter().enumerate() {
                    for column in 0..8 {
                        if bits & (1 << column) != 0 {
                            self.pixel((cursor + column) as i32, (y + row as u32) as i32, color);
                        }
                    }
                }
            }
            cursor += 8;
        }
    }

    fn pixel_3d(&mut self, x: i32, y: i32, z: f32, color: Color) {
        if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 {
            return;
        }
        let index = y as usize * self.width as usize + x as usize;
        if z < self.depth[index] {
            return;
        }
        self.depth[index] = z;
        let offset = index * 4;
        self.pixels[offset..offset + 4].copy_from_slice(&color);
    }

    fn pixel(&mut self, x: i32, y: i32, color: Color) {
        if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 {
            return;
        }
        let offset = (y as usize * self.width as usize + x as usize) * 4;
        if color[3] == 255 {
            self.pixels[offset..offset + 4].copy_from_slice(&color);
            return;
        }
        let alpha = color[3] as f32 / 255.0;
        for (channel, source) in color.iter().take(3).enumerate() {
            self.pixels[offset + channel] = (self.pixels[offset + channel] as f32 * (1.0 - alpha)
                + *source as f32 * alpha)
                .round() as u8;
        }
        self.pixels[offset + 3] = 255;
    }
}

pub fn mix(from: Color, to: Color, amount: f32) -> Color {
    let amount = amount.clamp(0.0, 1.0);
    [
        (from[0] as f32 + (to[0] as f32 - from[0] as f32) * amount).round() as u8,
        (from[1] as f32 + (to[1] as f32 - from[1] as f32) * amount).round() as u8,
        (from[2] as f32 + (to[2] as f32 - from[2] as f32) * amount).round() as u8,
        255,
    ]
}

pub fn scale(color: Color, amount: f32) -> Color {
    [
        (color[0] as f32 * amount).clamp(0.0, 255.0) as u8,
        (color[1] as f32 * amount).clamp(0.0, 255.0) as u8,
        (color[2] as f32 * amount).clamp(0.0, 255.0) as u8,
        color[3],
    ]
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn nearer_pixels_win_depth_test() {
        let mut canvas = Canvas::new(2, 2, [0, 0, 0, 255]);
        canvas.pixel_3d(0, 0, 1.0, [255, 0, 0, 255]);
        canvas.pixel_3d(0, 0, 0.0, [0, 255, 0, 255]);
        assert_eq!(&canvas.pixels[..4], &[255, 0, 0, 255]);
    }
}