renderer.rs

15.3 kB · rust · 455 lines

1use super::geometry::{is_hex, orientation, tile};2use super::models::Cell6d;3use super::painter::paint;4use super::Orientation;5use mrlycore::colors::Color;6use mrlycore::enums::Mode;7use mrlycore::errors::{value_error, Result};8use mrlycore::resample::{hex_fit, Filter};910/// A screen triangle: three grid points and an RGBA color.11pub type Triangle = ([(i64, i64); 3], [u8; 4]);1213/// A rectangular window onto a triangle sheet, the frame every rendering draws through.14#[derive(Clone, Debug)]15pub struct Rect {16    /// The triangles of the sheet the window looks onto.17    pub triangles: Vec<Triangle>,18    /// The window's top-left corner, in grid points.19    pub origin: (i64, i64),20    /// The window's width and height, in grid points.21    pub size: (usize, usize),22}2324fn north(x: i64, y: i64) -> [(i64, i64); 3] {25    [(x, 2 * y + 2), (x + 1, 2 * y), (x + 2, 2 * y + 2)]26}2728fn south(x: i64, y: i64) -> [(i64, i64); 3] {29    [(x, 2 * y), (x + 1, 2 * y + 2), (x + 2, 2 * y)]30}3132fn east(x: i64, y: i64) -> [(i64, i64); 3] {33    [(2 * x, y), (2 * x, y + 2), (2 * x + 2, y + 1)]34}3536fn west(x: i64, y: i64) -> [(i64, i64); 3] {37    [(2 * x + 2, y), (2 * x + 2, y + 2), (2 * x, y + 1)]38}3940fn painted(cell: &Cell6d) -> Vec<[u8; 4]> {41    match &cell.cell.cell.colors {42        Some(colors) => colors.clone(),43        None => paint(cell.clone(), None, Some(Mode::Type))44            .cell45            .cell46            .colors47            .unwrap(),48    }49}5051/// Folds a cell into colored screen triangles, dropping the transparent ones.52pub fn triangles(cell: &Cell6d) -> Result<Vec<Triangle>> {53    let inner = &cell.cell;54    let (height, width) = (inner.height(), inner.width());55    let colors = painted(cell);56    let orient = orientation(width, height)?;57    let start = cell.start as i64;58    let mut out = Vec::new();59    for y in 0..height {60        for x in 0..width {61            let rgba = colors[y * width + x];62            if rgba[3] == 0 {63                continue;64            }65            let flip = (x as i64 + y as i64 + start).rem_euclid(2);66            let points = match orient {67                Orientation::Horizontal => {68                    if flip == 0 {69                        north(x as i64, y as i64)70                    } else {71                        south(x as i64, y as i64)72                    }73                }74                Orientation::Vertical => {75                    if flip == 0 {76                        east(x as i64, y as i64)77                    } else {78                        west(x as i64, y as i64)79                    }80                }81            };82            out.push((points, rgba));83        }84    }85    Ok(out)86}8788fn bounds(tris: &[Triangle]) -> (i64, i64, i64, i64) {89    let xs = tris.iter().flat_map(|(p, _)| p.iter().map(|q| q.0));90    let ys = tris.iter().flat_map(|(p, _)| p.iter().map(|q| q.1));91    let min_x = xs.clone().min().unwrap();92    let max_x = xs.max().unwrap();93    let min_y = ys.clone().min().unwrap();94    let max_y = ys.max().unwrap();95    (min_x, max_x, min_y, max_y)96}9798fn window(cell: &Cell6d, padding: i64) -> Result<Rect> {99    let triangles = triangles(cell)?;100    if triangles.is_empty() {101        return value_error("nothing to render.");102    }103    let (min_x, max_x, min_y, max_y) = bounds(&triangles);104    Ok(Rect {105        triangles,106        origin: (min_x - padding, min_y - padding),107        size: (108            (max_x - min_x + 2 * padding) as usize,109            (max_y - min_y + 2 * padding) as usize,110        ),111    })112}113114fn stroke_of(outline: Option<Color>, width: usize) -> String {115    match outline {116        Some(c) => format!("stroke=\"{}\" stroke-width=\"{width}\"", c.to_hex()),117        None => "stroke=\"none\"".to_string(),118    }119}120121fn polygon(122    points: &[(i64, i64); 3],123    rgba: [u8; 4],124    origin: (i64, i64),125    scale: usize,126    stroke: &str,127) -> String {128    let [r, g, b, a] = rgba;129    let fill = Color::rgba(r, g, b, a).to_hex();130    let pts: Vec<String> = points131        .iter()132        .map(|(x, y)| {133            format!(134                "{},{}",135                (x - origin.0) * scale as i64,136                (y - origin.1) * scale as i64137            )138        })139        .collect();140    format!(141        "<polygon points=\"{}\" fill=\"{fill}\" {stroke}/>",142        pts.join(" ")143    )144}145146fn sheet(view: &Rect, scale: usize, stroke: &str) -> String {147    let (img_w, img_h) = (view.size.0 * scale, view.size.1 * scale);148    let mut out = vec![format!(149        "<svg width=\"{img_w}\" height=\"{img_h}\" viewBox=\"0 0 {img_w} {img_h}\" xmlns=\"http://www.w3.org/2000/svg\">"150    )];151    for (points, rgba) in &view.triangles {152        out.push(polygon(points, *rgba, view.origin, scale, stroke));153    }154    out.push("</svg>".to_string());155    out.join("\n")156}157158/// Renders a cell's triangles to an SVG string at the given scale, stroked and padded when an outline is given, or an error when nothing renders.159pub fn svg(cell: &Cell6d, scale: usize, outline: Option<Color>, width: usize) -> Result<String> {160    let padding = if outline.is_some() { width as i64 } else { 0 };161    Ok(sheet(162        &window(cell, padding)?,163        scale,164        &stroke_of(outline, width),165    ))166}167168fn fill_triangles(169    tris: &[Triangle],170    origin: (i64, i64),171    size: (usize, usize),172    scale: usize,173) -> Vec<[u8; 4]> {174    let (img_w, img_h) = size;175    let mut pixels = vec![[0u8; 4]; img_w * img_h];176    for (points, rgba) in tris {177        let scaled: Vec<(f64, f64)> = points178            .iter()179            .map(|(x, y)| {180                (181                    ((x - origin.0) * scale as i64) as f64,182                    ((y - origin.1) * scale as i64) as f64,183                )184            })185            .collect();186        let span = |pick: fn(&(f64, f64)) -> f64, limit: usize| -> (usize, usize) {187            let lo = scaled.iter().map(pick).fold(f64::MAX, f64::min);188            let hi = scaled.iter().map(pick).fold(f64::MIN, f64::max);189            (190                lo.floor().clamp(0.0, limit as f64) as usize,191                hi.ceil().clamp(0.0, limit as f64) as usize,192            )193        };194        let (x0, x1) = span(|p| p.0, img_w);195        let (y0, y1) = span(|p| p.1, img_h);196        let edge = |a: (f64, f64), b: (f64, f64), p: (f64, f64)| -> f64 {197            (b.0 - a.0) * (p.1 - a.1) - (b.1 - a.1) * (p.0 - a.0)198        };199        for py in y0..y1 {200            for px in x0..x1 {201                let p = (px as f64 + 0.5, py as f64 + 0.5);202                let e0 = edge(scaled[0], scaled[1], p);203                let e1 = edge(scaled[1], scaled[2], p);204                let e2 = edge(scaled[2], scaled[0], p);205                let inside =206                    (e0 >= 0.0 && e1 >= 0.0 && e2 >= 0.0) || (e0 <= 0.0 && e1 <= 0.0 && e2 <= 0.0);207                if inside {208                    pixels[py * img_w + px] = *rgba;209                }210            }211        }212    }213    pixels214}215216fn canvas(view: &Rect, scale: usize) -> ((usize, usize), Vec<[u8; 4]>) {217    let size = (view.size.0 * scale, view.size.1 * scale);218    (219        size,220        fill_triangles(&view.triangles, view.origin, size, scale),221    )222}223224fn frame(view: &Rect, scale: usize) -> Result<Vec<u8>> {225    let ((img_w, img_h), pixels) = canvas(view, scale);226    mrlycore::io::png(&pixels, img_w, img_h, 1)227}228229/// Rasterizes a cell's triangles to PNG bytes at the given scale, or an error when nothing renders.230pub fn png(cell: &Cell6d, scale: usize) -> Result<Vec<u8>> {231    frame(&window(cell, 0)?, scale)232}233234/// Rasterizes a cell's triangles to PNG bytes squashed to the true hex aspect, the stretched axis resampled by the filter.235pub fn hex_png(cell: &Cell6d, scale: usize, filter: Filter) -> Result<Vec<u8>> {236    let vertical = orientation(cell.width(), cell.height())? == Orientation::Vertical;237    let ((width, height), pixels) = canvas(&window(cell, 0)?, scale);238    let (img_w, img_h, fitted) = hex_fit(&pixels, width, height, vertical, filter)?;239    mrlycore::io::png(&fitted, img_w, img_h, 1)240}241242/// Tessellates a hexagon and frames the rectangular fundamental domain of that tiling.243pub fn rect(cell: &Cell6d) -> Result<Rect> {244    let inner = &cell.cell;245    if !is_hex(inner) {246        return value_error("Cell must be a hexagon.");247    }248    let (tile_h, tile_w) = (inner.height(), inner.width());249    let (step_x, step_y, origin) = match orientation(tile_w, tile_h)? {250        Orientation::Horizontal => (251            (3 * (tile_w + 1)) / 4,252            tile_h,253            (tile_w.div_ceil(2) as i64, tile_h as i64),254        ),255        Orientation::Vertical => (256            tile_w,257            (3 * (tile_h + 1)) / 4,258            (tile_w as i64, tile_h.div_ceil(2) as i64),259        ),260    };261    let sheet = tile(cell, 3, 3)?;262    let orient = orientation(sheet.width(), sheet.height())?;263    let tiled = Cell6d::new(sheet, cell.projection, orient, cell.start);264    Ok(Rect {265        triangles: triangles(&tiled)?,266        origin,267        size: (2 * step_x, 2 * step_y),268    })269}270271/// Renders the hexagon's rectangular fundamental domain to an SVG string at the given scale, stroked when an outline is given.272pub fn rect_svg(273    cell: &Cell6d,274    scale: usize,275    outline: Option<Color>,276    width: usize,277) -> Result<String> {278    Ok(sheet(&rect(cell)?, scale, &stroke_of(outline, width)))279}280281/// Rasterizes the hexagon's rectangular fundamental domain to PNG bytes at the given scale.282pub fn rect_png(cell: &Cell6d, scale: usize) -> Result<Vec<u8>> {283    frame(&rect(cell)?, scale)284}285286#[cfg(test)]287mod tests {288    use super::*;289    use crate::six::designs::iso_design;290    use crate::six::geometry::blank;291    use crate::six::models::Cell6d;292    use crate::six::{Orientation, Projection};293    #[test]294    fn triangle_geometry() {295        assert_eq!(north(0, 0), [(0, 2), (1, 0), (2, 2)]);296        assert_eq!(south(0, 0), [(0, 0), (1, 2), (2, 0)]);297        assert_eq!(east(1, 1), [(2, 1), (2, 3), (4, 2)]);298    }299    #[test]300    fn iso_renders_triangles() {301        let i = iso_design(23, 3, 1, 2).unwrap();302        let tris = triangles(&i).unwrap();303        assert!(!tris.is_empty());304        let s = svg(&i, 10, None, 1).unwrap();305        assert!(s.contains("<polygon"));306        assert!(s.contains("stroke=\"none\""));307        let bytes = png(&i, 10).unwrap();308        assert_eq!(&bytes[0..8], &[137, 80, 78, 71, 13, 10, 26, 10]);309    }310    #[test]311    fn outline_strokes_and_pads_the_svg() {312        let i = iso_design(23, 3, 1, 2).unwrap();313        let plain = svg(&i, 4, None, 1).unwrap();314        let lined = svg(&i, 4, Some(Color::rgba(255, 0, 0, 255)), 2).unwrap();315        assert!(lined.contains("stroke-width=\"2\""));316        assert!(lined.contains(&Color::rgba(255, 0, 0, 255).to_hex()));317        let size = |svg: &str| -> usize {318            svg.split("width=\"")319                .nth(1)320                .unwrap()321                .split('"')322                .next()323                .unwrap()324                .parse()325                .unwrap()326        };327        assert_eq!(size(&lined), size(&plain) + 4 * 4);328    }329    #[test]330    fn hexagon_renders() {331        let hex = Cell6d::new(332            blank(3, Orientation::Horizontal, 1, 0),333            Projection::Cut,334            Orientation::Horizontal,335            0,336        );337        let tris = triangles(&hex).unwrap();338        assert!(!tris.is_empty());339    }340    fn tiling_hex(radius: usize, orient: Orientation) -> Cell6d {341        Cell6d::new(342            blank(radius, orient, crate::six::FILL, crate::six::GRID),343            Projection::Cut,344            orient,345            0,346        )347    }348    #[test]349    fn rect_frames_the_fundamental_domain() {350        let hex = tiling_hex(2, Orientation::Horizontal);351        let domain = rect(&hex).unwrap();352        assert_eq!(domain.size, (12, 8));353        assert_eq!(domain.origin, (4, 4));354        let s = rect_svg(&hex, 3, None, 1).unwrap();355        assert!(s.contains("viewBox=\"0 0 36 24\""));356        assert!(s.contains("stroke=\"none\""));357        let lined = rect_svg(&hex, 3, Some(Color::rgba(0, 0, 255, 255)), 2).unwrap();358        assert!(lined.contains("viewBox=\"0 0 36 24\""));359        assert!(lined.contains("stroke-width=\"2\""));360        assert!(lined.contains(&Color::rgba(0, 0, 255, 255).to_hex()));361        let bytes = rect_png(&hex, 3).unwrap();362        assert_eq!(&bytes[0..8], &[137, 80, 78, 71, 13, 10, 26, 10]);363        let vertical = tiling_hex(2, Orientation::Vertical);364        let tall = rect(&vertical).unwrap();365        assert!(tall.size.1 > tall.size.0);366        assert!(rect(&Cell6d::new(367            crate::two::Cell2d::new(mrlycore::Tensor::full(vec![4, 4], 1)),368            Projection::Cut,369            Orientation::Horizontal,370            0,371        ))372        .is_err());373    }374    #[test]375    fn rect_window_has_no_gaps() {376        for orient in [Orientation::Horizontal, Orientation::Vertical] {377            let hex = tiling_hex(3, orient);378            let domain = rect(&hex).unwrap();379            let pixels = fill_triangles(&domain.triangles, domain.origin, domain.size, 1);380            let clear = pixels.iter().filter(|p| p[3] == 0).count();381            assert_eq!(clear, 0, "{orient:?} of {} pixels", pixels.len());382        }383    }384    #[test]385    fn nothing_to_render_errors_in_both_doors() {386        let bare = Cell6d::new(387            crate::two::Cell2d::new(mrlycore::Tensor::full(vec![2, 3], crate::six::GRID)),388            Projection::Cut,389            Orientation::Horizontal,390            0,391        );392        assert!(triangles(&bare).unwrap().is_empty());393        assert!(svg(&bare, 4, None, 1).is_err());394        assert!(png(&bare, 4).is_err());395        assert!(hex_png(&bare, 4, Filter::Box).is_err());396    }397    #[test]398    fn hex_png_squashes_the_stretched_axis() {399        let sides = |bytes: &[u8]| -> (usize, usize) {400            let (w, h, _) = mrlycore::unpng(bytes).unwrap();401            (w, h)402        };403        let tall = iso_design(23, 3, 1, 2).unwrap();404        let (w, h) = sides(&png(&tall, 4).unwrap());405        assert_eq!(406            sides(&hex_png(&tall, 4, Filter::Box).unwrap()),407            mrlycore::resample::hex_size(w, h, true)408        );409        let wide = tiling_hex(3, Orientation::Horizontal);410        let (w, h) = sides(&png(&wide, 4).unwrap());411        assert_eq!(412            sides(&hex_png(&wide, 4, Filter::Nearest).unwrap()),413            mrlycore::resample::hex_size(w, h, false)414        );415    }416}417418#[cfg(test)]419mod golden {420    use super::*;421    use crate::six::designs::iso_design;422    #[test]423    fn png_pixels_stay_pinned() {424        let inks = [425            [0, 0, 0, 0],426            [0, 140, 255, 255],427            [50, 204, 88, 255],428            [255, 61, 64, 255],429        ];430        let cases = [431            (432                png(&iso_design(23, 3, 1, 2).unwrap(), 10).unwrap(),433                (120, 120),434                [3600, 3600, 3600, 3600],435                inks[1],436            ),437            (438                png(&iso_design(5, 4, 1, 2).unwrap(), 3).unwrap(),439                (42, 39),440                [522, 486, 144, 486],441                inks[3],442            ),443        ];444        for (bytes, size, counts, centre) in &cases {445            let (w, h, pixels) = mrlycore::unpng(bytes).unwrap();446            assert_eq!((w, h), *size);447            assert_eq!(pixels.len(), counts.iter().sum::<usize>());448            for (ink, count) in inks.iter().zip(counts) {449                assert_eq!(pixels.iter().filter(|p| *p == ink).count(), *count);450            }451            assert_eq!(pixels[0], inks[0]);452            assert_eq!(pixels[(h / 2) * w + w / 2], *centre);453        }454    }455}