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}