rust-ppm 0.1.1

Small RGB image and plotting library for generating PPM graphics
Documentation
use std::io;
use std::path::Path;

use crate::Image;

use super::{Axes, Canvas, LineStyle, MarkerStyle};

/// Default image dimensions for a plot.
pub const DEFAULT_SIZE: (usize, usize) = (640, 480);

/// A high-level, image-backed plot builder.
pub struct Plot {
    width: usize,
    height: usize,
    scale: usize,
    inset_x: usize,
    inset_y: usize,
    axes: Axes,
    series: Vec<PlotSeries>,
}

enum PlotSeries {
    Line(Vec<(f64, f64)>, LineStyle),
    Markers(Vec<(f64, f64)>, MarkerStyle),
}

impl Plot {
    /// Creates a 640x480 plot with automatic axis limits.
    pub fn new() -> Self {
        Self {
            width: DEFAULT_SIZE.0,
            height: DEFAULT_SIZE.1,
            scale: 1,
            inset_x: DEFAULT_SIZE.0 / 10,
            inset_y: DEFAULT_SIZE.1 / 10,
            axes: Axes::new(),
            series: Vec::new(),
        }
    }

    /// Sets the plotted image size in pixels.
    pub fn size(mut self, width: usize, height: usize) -> Self {
        let x_ratio = self.inset_x as f64 / self.width.max(1) as f64;
        let y_ratio = self.inset_y as f64 / self.height.max(1) as f64;
        self.width = width;
        self.height = height;
        self.inset_x = (width as f64 * x_ratio).round() as usize;
        self.inset_y = (height as f64 * y_ratio).round() as usize;
        self
    }

    /// Sets a render scale for higher-resolution output.
    pub fn scale(mut self, factor: usize) -> Self {
        assert!(factor > 0, "plot scale must be greater than zero");
        self.scale = factor;
        self
    }

    /// Sets horizontal and vertical plot-area margins in pixels.
    pub fn margins(mut self, horizontal: usize, vertical: usize) -> Self {
        self.inset_x = horizontal;
        self.inset_y = vertical;
        self
    }

    /// Replaces the plot axes configuration.
    pub fn axes(mut self, axes: Axes) -> Self {
        self.axes = axes;
        self
    }

    /// Adds a connected line series to the plot.
    pub fn line(mut self, points: &[(f64, f64)], style: LineStyle) -> Self {
        self.series.push(PlotSeries::Line(points.to_vec(), style));
        self
    }

    /// Adds a marker-based series to the plot.
    pub fn markers(mut self, points: &[(f64, f64)], style: MarkerStyle) -> Self {
        self.series
            .push(PlotSeries::Markers(points.to_vec(), style));
        self
    }

    /// Renders this plot into an RGB image.
    pub fn render(self) -> Image {
        let scale = self.scale;
        let mut canvas = Canvas::with_inset(
            self.width.saturating_mul(scale),
            self.height.saturating_mul(scale),
            self.axes,
            self.inset_x.saturating_mul(scale),
            self.inset_y.saturating_mul(scale),
        );
        canvas.set_render_scale(scale);
        canvas.render();
        for series in self.series {
            match series {
                PlotSeries::Line(points, mut style) => {
                    style.width = style.width.saturating_mul(scale);
                    canvas.line(&points, style);
                }
                PlotSeries::Markers(points, mut style) => {
                    style.size = style.size.saturating_mul(scale);
                    canvas.markers(&points, style);
                }
            }
        }
        canvas.into_image()
    }

    /// Renders and saves this plot as a PPM image.
    pub fn save(self, path: impl AsRef<Path>) -> io::Result<()> {
        self.render().save(path)
    }
}

impl Default for Plot {
    fn default() -> Self {
        Self::new()
    }
}

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

    #[test]
    fn builder_renders_multiple_styled_series() {
        let image = Plot::new()
            .size(11, 11)
            .margins(0, 0)
            .axes(Axes::from_limits((0.0, 1.0), (0.0, 1.0)))
            .line(
                &[(0.0, 0.0), (1.0, 1.0)],
                LineStyle::new().color(Pixel::rgb(0, 128, 0)),
            )
            .markers(
                &[(0.0, 1.0)],
                MarkerStyle::new().color(Pixel::rgb(255, 128, 0)),
            )
            .render();

        assert_eq!(image.get_pixel(5, 5), Some(&Pixel::rgb(0, 128, 0)));
        assert_eq!(image.get_pixel(0, 0), Some(&Pixel::rgb(255, 128, 0)));
    }

    #[test]
    fn scale_increases_output_resolution() {
        let image = Plot::new().size(100, 80).scale(2).render();
        assert_eq!((image.width, image.height), (200, 160));
    }
}