renderer.rs
13.9 kB · rust · 399 lines
1use super::models::Cell2d;2use crate::dim::push_glyph;3use mrlycore::cell::mapping;4use mrlycore::colors::Color;5use mrlycore::enums::Mode;6use mrlycore::errors::{value_error, Result};7use mrlycore::tensor::Tensor;8use std::collections::HashMap;910/// The mark an SVG rendering draws at each site.11#[derive(Clone, Copy, Debug, PartialEq, Eq)]12pub enum Shape {13 /// The full-cell square.14 Square,15 /// The inscribed circle.16 Circle,17 /// The inscribed diamond.18 Diamond,19}2021fn painted(cell: &Cell2d) -> Vec<[u8; 4]> {22 match &cell.cell.colors {23 Some(colors) => colors.clone(),24 None => {25 let fresh = cell.clone().paint(&mapping(), Mode::Type);26 fresh.cell.colors.unwrap()27 }28 }29}3031/// Renders the cell as rows of glyphs, or of digits where no glyph is mapped.32///33/// ```34/// let cell = mrlymath::two::carpet(3, 1).unwrap();35/// assert_eq!(mrlymath::two::text(&cell, None), vec!["111", "101", "111"]);36/// ```37pub fn text(cell: &Cell2d, glyphs: Option<&HashMap<u8, char>>) -> Vec<String> {38 let (h, w) = (cell.height(), cell.width());39 let mut rows = Vec::with_capacity(h);40 for y in 0..h {41 let mut row = String::with_capacity(w);42 for x in 0..w {43 let v = cell.types().get(&[y, x]);44 push_glyph(&mut row, v, glyphs);45 }46 rows.push(row);47 }48 rows49}5051/// Renders the cell to PNG bytes at the given pixel scale, painting it by type when unpainted.52pub fn png(cell: &Cell2d, scale: usize) -> Result<Vec<u8>> {53 let colors = painted(cell);54 mrlycore::io::png(&colors, cell.width(), cell.height(), scale)55}5657/// Reads PNG bytes back into a cell, its pixels kept as colors and its dark opaque sites filled.58///59/// ```60/// let cell = mrlymath::two::carpet(3, 1).unwrap();61/// let bytes = mrlymath::two::png(&cell, 1).unwrap();62/// let back = mrlymath::two::from_png(&bytes).unwrap();63/// assert_eq!(mrlymath::two::to_strings(&back), vec!["111", "101", "111"]);64/// ```65pub fn from_png(bytes: &[u8]) -> Result<Cell2d> {66 let (width, height, pixels) = mrlycore::codec::unpng(bytes)?;67 let types: Vec<u8> = pixels68 .iter()69 .map(|&[r, g, b, a]| {70 let luminance = 299 * r as u32 + 587 * g as u32 + 114 * b as u32;71 (a != 0 && luminance < 128_000) as u872 })73 .collect();74 let mut cell = Cell2d::new(Tensor::of(types, vec![height, width]));75 cell.cell.colors = Some(pixels);76 Ok(cell)77}7879/// Renders cells into one PNG contact sheet, each centred in a slot the largest cell sizes.80///81/// The gap is drawn in the ground color and every measure is in cells, so the scale82/// multiplies the whole sheet at once.83pub fn montage(84 cells: &[Cell2d],85 columns: usize,86 scale: usize,87 gap: usize,88 ground: Color,89) -> Result<Vec<u8>> {90 if cells.is_empty() {91 return value_error("montage needs at least one cell.");92 }93 if columns == 0 {94 return value_error("montage needs at least one column.");95 }96 let slot_w = cells.iter().map(|c| c.width()).max().unwrap_or(0);97 let slot_h = cells.iter().map(|c| c.height()).max().unwrap_or(0);98 let rows = cells.len().div_ceil(columns);99 let sheet_w = columns * (slot_w + gap) + gap;100 let sheet_h = rows * (slot_h + gap) + gap;101 let mut pixels = vec![[ground.r, ground.g, ground.b, ground.a]; sheet_w * sheet_h];102 for (i, cell) in cells.iter().enumerate() {103 let colors = painted(cell);104 let (w, h) = (cell.width(), cell.height());105 let x0 = (i % columns) * (slot_w + gap) + gap + (slot_w - w) / 2;106 let y0 = (i / columns) * (slot_h + gap) + gap + (slot_h - h) / 2;107 for y in 0..h {108 for x in 0..w {109 let color = colors[y * w + x];110 if color[3] != 0 {111 pixels[(y0 + y) * sheet_w + x0 + x] = color;112 }113 }114 }115 }116 mrlycore::io::png(&pixels, sheet_w, sheet_h, scale)117}118119enum Mark {120 Outside,121 Rim,122 Inside,123}124125fn mark(shape: Shape, i: usize, j: usize, scale: usize, stroke: usize) -> Mark {126 let rim = scale as f64 / 2.0;127 let dx = i as f64 + 0.5 - rim;128 let dy = j as f64 + 0.5 - rim;129 let reach = match shape {130 Shape::Square => dx.abs().max(dy.abs()),131 Shape::Circle => (dx * dx + dy * dy).sqrt(),132 Shape::Diamond => dx.abs() + dy.abs(),133 };134 if reach > rim {135 Mark::Outside136 } else if stroke > 0 && reach > rim - stroke as f64 {137 Mark::Rim138 } else {139 Mark::Inside140 }141}142143/// Renders the cell to PNG bytes, each site drawn as the shape, stroked when an outline is given.144pub fn raster(145 cell: &Cell2d,146 scale: usize,147 shape: Shape,148 outline: Option<Color>,149 width: usize,150) -> Result<Vec<u8>> {151 let stroke = if outline.is_some() { width } else { 0 };152 let colors = painted(cell);153 let (h, w) = (cell.height(), cell.width());154 let (img_w, img_h) = (w * scale + stroke * 2, h * scale + stroke * 2);155 let mut pixels = vec![[0u8; 4]; img_w * img_h];156 let edge = outline.map(|c| [c.r, c.g, c.b, c.a]);157 for y in 0..h {158 for x in 0..w {159 let fill = colors[y * w + x];160 if fill[3] == 0 {161 continue;162 }163 let (x0, y0) = (x * scale + stroke, y * scale + stroke);164 for j in 0..scale {165 for i in 0..scale {166 let ink = match mark(shape, i, j, scale, stroke) {167 Mark::Outside => continue,168 Mark::Rim => edge.unwrap_or(fill),169 Mark::Inside => fill,170 };171 pixels[(y0 + j) * img_w + x0 + i] = ink;172 }173 }174 }175 }176 mrlycore::io::png(&pixels, img_w, img_h, 1)177}178179/// Renders the cell as SVG marks at the given scale, stroked and padded when an outline is given.180pub fn svg(181 cell: &Cell2d,182 scale: usize,183 shape: Shape,184 outline: Option<Color>,185 width: usize,186) -> String {187 let padding = if outline.is_some() { width } else { 0 };188 let colors = painted(cell);189 let (h, w) = (cell.height(), cell.width());190 let (img_w, img_h) = (w * scale + padding * 2, h * scale + padding * 2);191 let stroke = match outline {192 Some(c) => format!("stroke=\"{}\" stroke-width=\"{width}\"", c.to_hex()),193 None => "stroke=\"none\"".to_string(),194 };195 let mut out = vec![format!(196 "<svg width=\"{img_w}\" height=\"{img_h}\" xmlns=\"http://www.w3.org/2000/svg\">"197 )];198 for y in 0..h {199 for x in 0..w {200 let [r, g, b, a] = colors[y * w + x];201 if a == 0 {202 continue;203 }204 let fill = Color::rgba(r, g, b, a).to_hex();205 let (x0, y0) = (x * scale + padding, y * scale + padding);206 let element = match shape {207 Shape::Square => format!(208 "<rect x=\"{x0}\" y=\"{y0}\" width=\"{scale}\" height=\"{scale}\" fill=\"{fill}\" {stroke}/>"209 ),210 Shape::Circle => {211 let radius = scale as f64 / 2.0;212 let (cx, cy) = (x0 as f64 + radius, y0 as f64 + radius);213 format!("<circle cx=\"{cx}\" cy=\"{cy}\" r=\"{radius}\" fill=\"{fill}\" {stroke}/>")214 }215 Shape::Diamond => {216 let half = scale as f64 / 2.0;217 let (mx, my) = (x0 as f64 + half, y0 as f64 + half);218 let (x1, y1) = (x0 + scale, y0 + scale);219 format!(220 "<polygon points=\"{mx},{y0} {x1},{my} {mx},{y1} {x0},{my}\" fill=\"{fill}\" {stroke}/>"221 )222 }223 };224 out.push(element);225 }226 }227 out.push("</svg>".to_string());228 out.join("\n")229}230231#[cfg(test)]232mod tests {233 use super::*;234 use crate::two::designs;235 #[test]236 fn text_digits_and_glyphs() {237 let c = designs::carpet(3, 1).unwrap();238 let t = text(&c, None);239 assert_eq!(t, vec!["111", "101", "111"]);240 let glyphs = HashMap::from([(0, ' '), (1, '#')]);241 assert_eq!(text(&c, Some(&glyphs)), vec!["###", "# #", "###"]);242 }243 #[test]244 fn png_signature_and_size() {245 let c = designs::carpet(3, 2).unwrap();246 let bytes = png(&c, 4).unwrap();247 assert_eq!(&bytes[0..8], &[137, 80, 78, 71, 13, 10, 26, 10]);248 assert!(bytes.len() > 100);249 }250 #[test]251 fn svg_counts_filled() {252 let c = designs::carpet(3, 1).unwrap();253 let s = svg(&c, 10, Shape::Square, None, 0);254 assert_eq!(s.matches("<rect").count(), 9);255 assert!(s.starts_with("<svg width=\"30\" height=\"30\""));256 let d = svg(&c, 10, Shape::Diamond, None, 0);257 assert_eq!(d.matches("<polygon").count(), 9);258 }259 #[test]260 fn from_png_round_trips_a_rendering() {261 let c = designs::carpet(3, 2).unwrap();262 let bytes = png(&c, 1).unwrap();263 let back = from_png(&bytes).unwrap();264 assert_eq!(back.width(), 9);265 assert_eq!(back.types(), c.types());266 assert_eq!(png(&back, 1).unwrap(), bytes);267 assert!(from_png(b"not a png").is_err());268 }269 #[test]270 fn from_png_reads_a_scaled_rendering() {271 let c = designs::htree(5, 1).unwrap();272 let back = from_png(&png(&c, 3).unwrap()).unwrap();273 assert_eq!((back.width(), back.height()), (15, 15));274 assert_eq!(back.types().sum(), 9 * c.types().sum());275 }276 #[test]277 fn raster_sizes_match_the_outline_padding() {278 let c = designs::carpet(3, 1).unwrap();279 let bare = raster(&c, 8, Shape::Square, None, 0).unwrap();280 assert_eq!(&bare[0..8], &[137, 80, 78, 71, 13, 10, 26, 10]);281 assert_eq!(from_png(&bare).unwrap().width(), 24);282 let edged = raster(&c, 8, Shape::Square, Some(mrlycore::colors::RED), 2).unwrap();283 assert_eq!(from_png(&edged).unwrap().width(), 28);284 }285 #[test]286 fn raster_shapes_carve_the_corners() {287 let c = designs::ones(1, 1).unwrap();288 let counts: Vec<usize> = [Shape::Square, Shape::Circle, Shape::Diamond]289 .into_iter()290 .map(|shape| {291 let bytes = raster(&c, 16, shape, None, 0).unwrap();292 let cell = from_png(&bytes).unwrap();293 cell.cell294 .colors295 .as_ref()296 .unwrap()297 .iter()298 .filter(|p| p[3] != 0)299 .count()300 })301 .collect();302 assert_eq!(counts, vec![256, 208, 144]);303 }304 #[test]305 fn montage_lays_cells_out_in_a_gapped_grid() {306 let ground = mrlycore::colors::RED;307 let cells = vec![designs::carpet(3, 1).unwrap(); 4];308 let bytes = montage(&cells, 2, 1, 1, ground).unwrap();309 let sheet = from_png(&bytes).unwrap();310 assert_eq!((sheet.width(), sheet.height()), (9, 9));311 let colors = sheet.cell.colors.as_ref().unwrap();312 let ink = [ground.r, ground.g, ground.b, 255];313 for x in 0..9 {314 assert_eq!(colors[x], ink, "top gap at {x}");315 assert_eq!(colors[4 * 9 + x], ink, "middle gap at {x}");316 }317 assert_eq!(colors[9 + 1], [0, 0, 0, 255]);318 assert_eq!(colors[2 * 9 + 2], [255, 255, 255, 255]);319 }320321 #[test]322 fn montage_centres_smaller_cells_in_the_slot() {323 let cells = vec![designs::ones(4, 1).unwrap(), designs::ones(2, 1).unwrap()];324 let bytes = montage(&cells, 2, 1, 0, mrlycore::colors::WHITE).unwrap();325 let sheet = from_png(&bytes).unwrap();326 assert_eq!((sheet.width(), sheet.height()), (8, 4));327 let colors = sheet.cell.colors.as_ref().unwrap();328 assert_eq!(colors[0], [0, 0, 0, 255]);329 assert_eq!(colors[5], [255, 255, 255, 255]);330 assert_eq!(colors[8 + 5], [0, 0, 0, 255]);331 assert_eq!(colors[3 * 8 + 5], [255, 255, 255, 255]);332 }333334 #[test]335 fn montage_rejects_an_empty_sheet() {336 let cells = vec![designs::carpet(3, 1).unwrap()];337 assert!(montage(&[], 2, 1, 1, mrlycore::colors::WHITE).is_err());338 assert!(montage(&cells, 0, 1, 1, mrlycore::colors::WHITE).is_err());339 assert!(montage(&cells, 1, 0, 1, mrlycore::colors::WHITE).is_err());340 }341342 #[test]343 fn raster_outline_paints_the_rim() {344 let c = designs::ones(1, 1).unwrap();345 let bytes = raster(&c, 9, Shape::Square, Some(mrlycore::colors::RED), 1).unwrap();346 let back = from_png(&bytes).unwrap();347 let colors = back.cell.colors.as_ref().unwrap();348 let red = [349 mrlycore::colors::RED.r,350 mrlycore::colors::RED.g,351 mrlycore::colors::RED.b,352 255,353 ];354 assert_eq!(back.width(), 11);355 assert_eq!(colors[0], [0, 0, 0, 0]);356 assert_eq!(colors[back.width() + 1], red);357 assert_eq!(colors[5 * back.width() + 5], [0, 0, 0, 255]);358 }359}360361#[cfg(test)]362mod golden {363 use super::*;364 use crate::two::designs;365 #[test]366 fn png_pixels_stay_pinned() {367 let black = [0, 0, 0, 255];368 let white = [255, 255, 255, 255];369 let cases = [370 (371 png(&designs::carpet(3, 2).unwrap(), 4).unwrap(),372 36,373 1024,374 white,375 ),376 (377 png(&designs::htree(5, 1).unwrap(), 1).unwrap(),378 5,379 15,380 black,381 ),382 (383 png(&designs::vtree(7, 1).unwrap(), 3).unwrap(),384 21,385 252,386 white,387 ),388 ];389 for (bytes, side, inked, centre) in &cases {390 let (w, h, pixels) = mrlycore::unpng(bytes).unwrap();391 assert_eq!((w, h), (*side, *side));392 assert!(pixels.iter().all(|p| *p == black || *p == white));393 assert_eq!(pixels.iter().filter(|p| **p == black).count(), *inked);394 assert_eq!(pixels[0], black);395 assert_eq!(pixels[(side / 2) * side + side / 2], *centre);396 assert_eq!(pixels[side * side - 1], black);397 }398 }399}