use crate::ink; use mrlycore::codec; use mrlycore::errors::Result; use mrlycore::Color; // FRAME /// A rectangle of board space: the area a figure lays itself out in. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Frame { /// The left edge in pixels. pub x: f64, /// The top edge in pixels. pub y: f64, /// The width in pixels. pub w: f64, /// The height in pixels. pub h: f64, } impl Frame { /// Builds a frame from its corner and its size. pub fn new(x: f64, y: f64, w: f64, h: f64) -> Frame { Frame { x, y, w, h } } /// Shrinks the frame by the same number of pixels on every side. pub fn inset(&self, px: f64) -> Frame { Frame::new( self.x + px, self.y + px, self.w - 2.0 * px, self.h - 2.0 * px, ) } /// Returns the width of one of n columns. pub fn cell(&self, n: usize) -> f64 { self.w / n as f64 } /// Maps unit coordinates, zero at the top left and one at the bottom right, to pixels. pub fn at(&self, u: f64, v: f64) -> (f64, f64) { (self.x + u * self.w, self.y + v * self.h) } /// Returns the middle of the frame. pub fn center(&self) -> (f64, f64) { self.at(0.5, 0.5) } /// Returns the largest square centred inside the frame. pub fn square(&self) -> Frame { let side = self.w.min(self.h); Frame::new( self.x + (self.w - side) / 2.0, self.y + (self.h - side) / 2.0, side, side, ) } /// Returns the shorter half-side, the radius a centred disc fills the frame with. pub fn radius(&self) -> f64 { self.w.min(self.h) / 2.0 } /// Splits the frame into n columns, left to right. pub fn cols(&self, n: usize) -> Vec { let w = self.w / n as f64; (0..n) .map(|i| Frame::new(self.x + i as f64 * w, self.y, w, self.h)) .collect() } /// Splits the frame into n rows, top to bottom. pub fn rows(&self, n: usize) -> Vec { let h = self.h / n as f64; (0..n) .map(|i| Frame::new(self.x, self.y + i as f64 * h, self.w, h)) .collect() } } // GEOMETRY fn box_sdf(px: f64, py: f64, cx: f64, cy: f64, hw: f64, hh: f64) -> f64 { let qx = (px - cx).abs() - hw; let qy = (py - cy).abs() - hh; let outside = (qx.max(0.0).powi(2) + qy.max(0.0).powi(2)).sqrt(); outside + qx.max(qy).min(0.0) } fn segment_sdf(px: f64, py: f64, a: (f64, f64), b: (f64, f64)) -> f64 { let (vx, vy) = (b.0 - a.0, b.1 - a.1); let (wx, wy) = (px - a.0, py - a.1); let len = vx * vx + vy * vy; let t = if len <= f64::EPSILON { 0.0 } else { ((wx * vx + wy * vy) / len).clamp(0.0, 1.0) }; ((wx - t * vx).powi(2) + (wy - t * vy).powi(2)).sqrt() } fn polygon_sdf(px: f64, py: f64, pts: &[(f64, f64)]) -> f64 { let mut dist = f64::MAX; let mut inside = false; for i in 0..pts.len() { let a = pts[i]; let b = pts[(i + 1) % pts.len()]; dist = dist.min(segment_sdf(px, py, a, b)); if (a.1 > py) != (b.1 > py) && px < a.0 + (py - a.1) / (b.1 - a.1) * (b.0 - a.0) { inside = !inside; } } if inside { -dist } else { dist } } fn bounds(pts: &[(f64, f64)]) -> (f64, f64, f64, f64) { let mut b = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); for p in pts { b.0 = b.0.min(p.0); b.1 = b.1.min(p.1); b.2 = b.2.max(p.0); b.3 = b.3.max(p.1); } b } // BOARD /// The rgba canvas a figure is drawn on, row-major from the top left. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Board { /// The width in pixels. pub width: usize, /// The height in pixels. pub height: usize, /// The rgba pixels, one per point of the raster. pub pixels: Vec<[u8; 4]>, } impl Board { /// Builds a board of the given size flooded with the ground color. pub fn new(width: usize, height: usize, ground: Color) -> Board { Board { width, height, pixels: vec![[ground.r, ground.g, ground.b, ground.a]; width * height], } } /// The house figure: 1024 by 1024 on the ground of the theme in press. pub fn square() -> Board { Board::new(1024, 1024, ink::ground()) } /// The social card: 1200 by 630 on the ground of the theme in press. pub fn og() -> Board { Board::new(1200, 630, ink::ground()) } /// Returns the largest centred square left after a margin of the given fraction of the short side. pub fn frame(&self, margin: f64) -> Frame { let side = self.width.min(self.height) as f64 * (1.0 - 2.0 * margin); Frame::new( (self.width as f64 - side) / 2.0, (self.height as f64 - side) / 2.0, side, side, ) } /// Returns the whole board inset by a margin of the given fraction of the short side. pub fn area(&self, margin: f64) -> Frame { let pad = self.width.min(self.height) as f64 * margin; Frame::new(0.0, 0.0, self.width as f64, self.height as f64).inset(pad) } /// Composites one color over one pixel at the given coverage. pub fn blend(&mut self, x: usize, y: usize, c: Color, cover: f64) { if x >= self.width || y >= self.height { return; } let a = (c.a as f64 / 255.0) * cover.clamp(0.0, 1.0); if a <= 0.0 { return; } let i = y * self.width + x; let d = self.pixels[i]; let over = |s: u8, under: u8| (s as f64 * a + under as f64 * (1.0 - a)).round() as u8; let alpha = a + (d[3] as f64 / 255.0) * (1.0 - a); self.pixels[i] = [ over(c.r, d[0]), over(c.g, d[1]), over(c.b, d[2]), (alpha * 255.0).round() as u8, ]; } fn shade(&mut self, area: (f64, f64, f64, f64), c: Color, sdf: impl Fn(f64, f64) -> f64) { let x0 = (area.0 - 1.0).floor().max(0.0) as usize; let y0 = (area.1 - 1.0).floor().max(0.0) as usize; let x1 = (area.2 + 1.0).ceil().max(0.0) as usize; let y1 = (area.3 + 1.0).ceil().max(0.0) as usize; for py in y0..y1.min(self.height) { for px in x0..x1.min(self.width) { let d = sdf(px as f64 + 0.5, py as f64 + 0.5); self.blend(px, py, c, 0.5 - d); } } } /// Fills an axis-aligned rectangle. pub fn rect(&mut self, x: f64, y: f64, w: f64, h: f64, c: Color) { let (cx, cy) = (x + w / 2.0, y + h / 2.0); let (hw, hh) = (w / 2.0, h / 2.0); self.shade((x, y, x + w, y + h), c, |px, py| { box_sdf(px, py, cx, cy, hw, hh) }); } /// Fills a rectangle with rounded corners of the given radius. pub fn round_rect(&mut self, x: f64, y: f64, w: f64, h: f64, r: f64, c: Color) { let (cx, cy) = (x + w / 2.0, y + h / 2.0); let r = r.min(w / 2.0).min(h / 2.0).max(0.0); let (hw, hh) = (w / 2.0 - r, h / 2.0 - r); self.shade((x, y, x + w, y + h), c, |px, py| { box_sdf(px, py, cx, cy, hw, hh) - r }); } /// Fills a disc. pub fn disc(&mut self, cx: f64, cy: f64, r: f64, c: Color) { self.shade((cx - r, cy - r, cx + r, cy + r), c, |px, py| { ((px - cx).powi(2) + (py - cy).powi(2)).sqrt() - r }); } /// Strokes a circle of the given radius, the stroke centred on it. pub fn ring(&mut self, cx: f64, cy: f64, r: f64, thick: f64, c: Color) { let outer = r + thick / 2.0; self.shade( (cx - outer, cy - outer, cx + outer, cy + outer), c, |px, py| (((px - cx).powi(2) + (py - cy).powi(2)).sqrt() - r).abs() - thick / 2.0, ); } /// Strokes a straight run between two points, with round caps. pub fn segment(&mut self, a: (f64, f64), b: (f64, f64), thick: f64, c: Color) { let half = thick / 2.0; let (bx0, by0, bx1, by1) = bounds(&[a, b]); self.shade( (bx0 - half, by0 - half, bx1 + half, by1 + half), c, |px, py| segment_sdf(px, py, a, b) - half, ); } /// Strokes a chain of points as one stroke, with round caps and joints. pub fn polyline(&mut self, pts: &[(f64, f64)], thick: f64, c: Color) { if pts.len() < 2 { return; } let half = thick / 2.0; let pad = half + 1.0; let (bx0, by0, bx1, by1) = bounds(pts); let x0 = (bx0 - pad).floor().max(0.0) as usize; let y0 = (by0 - pad).floor().max(0.0) as usize; let x1 = ((bx1 + pad).ceil().max(0.0) as usize).min(self.width); let y1 = ((by1 + pad).ceil().max(0.0) as usize).min(self.height); if x1 <= x0 || y1 <= y0 { return; } let span = x1 - x0; let mut mask = vec![0.0f64; span * (y1 - y0)]; for pair in pts.windows(2) { let (a, b) = (pair[0], pair[1]); let (sx0, sy0, sx1, sy1) = bounds(&[a, b]); let sx0 = (sx0 - pad).floor().max(x0 as f64) as usize; let sy0 = (sy0 - pad).floor().max(y0 as f64) as usize; let sx1 = ((sx1 + pad).ceil().max(0.0) as usize).min(x1); let sy1 = ((sy1 + pad).ceil().max(0.0) as usize).min(y1); for py in sy0..sy1 { let row = (py - y0) * span; for px in sx0..sx1 { let d = segment_sdf(px as f64 + 0.5, py as f64 + 0.5, a, b) - half; let cover = (0.5 - d).clamp(0.0, 1.0); let slot = &mut mask[row + (px - x0)]; if cover > *slot { *slot = cover; } } } } for py in y0..y1 { let row = (py - y0) * span; for px in x0..x1 { let cover = mask[row + (px - x0)]; if cover > 0.0 { self.blend(px, py, c, cover); } } } } /// Fills a triangle. pub fn triangle(&mut self, a: (f64, f64), b: (f64, f64), c: (f64, f64), color: Color) { self.polygon(&[a, b, c], color); } /// Fills any simple polygon, its inside decided by the even-odd rule. pub fn polygon(&mut self, pts: &[(f64, f64)], c: Color) { if pts.len() < 3 { return; } let (bx0, by0, bx1, by1) = bounds(pts); self.shade((bx0, by0, bx1, by1), c, |px, py| polygon_sdf(px, py, pts)); } /// Strokes the arc of a circle about a centre between two angles in radians, clockwise on the screen. pub fn arc(&mut self, center: (f64, f64), r: f64, angles: (f64, f64), thick: f64, c: Color) { let (cx, cy) = center; let (from, to) = angles; let half = thick / 2.0; let outer = r + half; let span = (to - from).abs(); let (lo, hi) = if to >= from { (from, to) } else { (to, from) }; let ends = [ (cx + r * lo.cos(), cy + r * lo.sin()), (cx + r * hi.cos(), cy + r * hi.sin()), ]; self.shade( (cx - outer, cy - outer, cx + outer, cy + outer), c, |px, py| { let angle = (py - cy).atan2(px - cx); let mut turn = angle - lo; while turn < 0.0 { turn += std::f64::consts::TAU; } if turn <= span.min(std::f64::consts::TAU) { (((px - cx).powi(2) + (py - cy).powi(2)).sqrt() - r).abs() - half } else { let d = ends .iter() .map(|e| ((px - e.0).powi(2) + (py - e.1).powi(2)).sqrt()) .fold(f64::MAX, f64::min); d - half } }, ); } /// Encodes the board as a png at one pixel per point. pub fn png(&self) -> Result> { codec::png(&self.pixels, self.width, self.height, 1) } } #[cfg(test)] mod tests { use super::*; #[test] fn frame_cells_tile_the_frame_exactly() { let frame = Board::square().frame(0.08); assert!((frame.cell(81) * 81.0 - frame.w).abs() < 1e-9); } #[test] fn a_disc_covers_its_own_area() { let mut board = Board::new(256, 256, ink::ground()); board.disc(128.0, 128.0, 90.0, ink::fg()); let (ground, fg) = (ink::ground().r as f64, ink::fg().r as f64); let lit: f64 = board .pixels .iter() .map(|p| (p[0] as f64 - ground) / (fg - ground)) .sum(); let want = std::f64::consts::PI * 90.0 * 90.0; assert!( (lit - want).abs() / want < 0.02, "covered {lit}, want {want}" ); } #[test] fn a_polyline_covers_its_stroke_area() { let mut board = Board::new(512, 512, ink::ground()); let thick = 6.0; let pts: Vec<(f64, f64)> = (0..1000) .map(|i| { let x = 6.0 + 500.0 * i as f64 / 999.0; (x, 256.0 + 100.0 * (std::f64::consts::TAU * x / 250.0).sin()) }) .collect(); board.polyline(&pts, thick, ink::fg()); let length: f64 = pts .windows(2) .map(|p| ((p[1].0 - p[0].0).powi(2) + (p[1].1 - p[0].1).powi(2)).sqrt()) .sum(); let (ground, fg) = (ink::ground().r as f64, ink::fg().r as f64); let lit: f64 = board .pixels .iter() .map(|p| (p[0] as f64 - ground) / (fg - ground)) .sum(); let want = length * thick + std::f64::consts::PI * (thick / 2.0).powi(2); assert!( (lit - want).abs() / want < 0.03, "covered {lit}, want {want}" ); } #[test] fn a_two_point_polyline_is_a_segment() { let (a, b) = ((17.3, 40.9), (190.7, 123.4)); let mut one = Board::new(256, 192, ink::ground()); one.segment(a, b, 7.0, ink::fg()); let mut two = Board::new(256, 192, ink::ground()); two.polyline(&[a, b], 7.0, ink::fg()); assert_eq!(one.pixels, two.pixels); } #[test] fn the_og_board_is_the_social_card_size() { let board = Board::og(); assert_eq!((board.width, board.height), (1200, 630)); } }