molx 0.1.6

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>,
    text_scale: u32,
}

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],
            text_scale: 1,
        }
    }

    pub fn set_text_scale(&mut self, scale: u32) {
        self.text_scale = scale.clamp(1, 3);
    }

    pub fn text_cell_width(&self) -> u32 {
        8 * self.text_scale
    }

    pub fn text_cell_height(&self) -> u32 {
        8 * self.text_scale
    }

    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 triangle_3d(
        &mut self,
        first: [f32; 3],
        second: [f32; 3],
        third: [f32; 3],
        color: Color,
    ) {
        self.triangle_3d_gradient(first, second, third, color, color, color);
    }

    pub fn triangle_3d_gradient(
        &mut self,
        first: [f32; 3],
        second: [f32; 3],
        third: [f32; 3],
        first_color: Color,
        second_color: Color,
        third_color: Color,
    ) {
        let area = edge(first, second, third[0], third[1]);
        if area.abs() < 0.001 {
            return;
        }
        let sign = area.signum();
        let min_x = first[0].min(second[0]).min(third[0]).floor().max(0.0) as i32;
        let max_x = first[0]
            .max(second[0])
            .max(third[0])
            .ceil()
            .min(self.width.saturating_sub(1) as f32) as i32;
        let min_y = first[1].min(second[1]).min(third[1]).floor().max(0.0) as i32;
        let max_y = first[1]
            .max(second[1])
            .max(third[1])
            .ceil()
            .min(self.height.saturating_sub(1) as f32) as i32;
        let inverse_area = 1.0 / area.abs();
        for y in min_y..=max_y {
            for x in min_x..=max_x {
                let px = x as f32 + 0.5;
                let py = y as f32 + 0.5;
                let w0 = edge(second, third, px, py) * sign;
                let w1 = edge(third, first, px, py) * sign;
                let w2 = edge(first, second, px, py) * sign;
                if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 {
                    continue;
                }
                let z = (w0 * first[2] + w1 * second[2] + w2 * third[2]) * inverse_area;
                let weights = [w0 * inverse_area, w1 * inverse_area, w2 * inverse_area];
                let mut color = [0_u8; 4];
                for channel in 0..4 {
                    color[channel] = (first_color[channel] as f32 * weights[0]
                        + second_color[channel] as f32 * weights[1]
                        + third_color[channel] as f32 * weights[2])
                        .round()
                        .clamp(0.0, 255.0) as u8;
                }
                self.pixel_3d(x, y, z, color);
            }
        }
    }

    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 stroke_rect(&mut self, x: u32, y: u32, width: u32, height: u32, color: Color) {
        if width == 0 || height == 0 {
            return;
        }
        for px in x..x.saturating_add(width).min(self.width) {
            self.pixel(px as i32, y as i32, color);
            self.pixel(
                px as i32,
                y.saturating_add(height.saturating_sub(1)) as i32,
                color,
            );
        }
        for py in y..y.saturating_add(height).min(self.height) {
            self.pixel(x as i32, py as i32, color);
            self.pixel(
                x.saturating_add(width.saturating_sub(1)) as i32,
                py as i32,
                color,
            );
        }
    }

    pub fn set_pixel(&mut self, x: u32, y: u32, color: Color) {
        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;
        let scale = self.text_scale;
        let cell_width = self.text_cell_width();
        for character in value.chars() {
            if cursor + cell_width > 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 {
                            let pixel_x = cursor + column * scale;
                            let pixel_y = y + row as u32 * scale;
                            for offset_y in 0..scale {
                                for offset_x in 0..scale {
                                    self.pixel(
                                        (pixel_x + offset_x) as i32,
                                        (pixel_y + offset_y) as i32,
                                        color,
                                    );
                                }
                            }
                        }
                    }
                }
            }
            cursor += cell_width;
        }
    }

    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;
    }
}

fn edge(first: [f32; 3], second: [f32; 3], x: f32, y: f32) -> f32 {
    (x - first[0]) * (second[1] - first[1]) - (y - first[1]) * (second[0] - first[0])
}

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]);
    }

    #[test]
    fn triangle_rasterizes_its_interior() {
        let mut canvas = Canvas::new(8, 8, [0, 0, 0, 255]);
        canvas.triangle_3d(
            [1.0, 1.0, 0.5],
            [6.0, 1.0, 0.5],
            [1.0, 6.0, 0.5],
            [10, 20, 30, 255],
        );
        let offset = (2 * 8 + 2) * 4;
        assert_eq!(&canvas.pixels[offset..offset + 4], &[10, 20, 30, 255]);
    }

    #[test]
    fn triangle_interpolates_vertex_colors() {
        let mut canvas = Canvas::new(8, 8, [0, 0, 0, 255]);
        canvas.triangle_3d_gradient(
            [1.0, 1.0, 0.5],
            [6.0, 1.0, 0.5],
            [1.0, 6.0, 0.5],
            [255, 0, 0, 255],
            [0, 255, 0, 255],
            [0, 0, 255, 255],
        );
        let offset = (2 * 8 + 2) * 4;
        let color = &canvas.pixels[offset..offset + 4];
        assert!(color[0] > 0 && color[1] > 0 && color[2] > 0);
    }
}