board.rs

14.2 kB · rust · 410 lines

1use crate::ink;2use mrlycore::codec;3use mrlycore::errors::Result;4use mrlycore::Color;56// FRAME78/// A rectangle of board space: the area a figure lays itself out in.9#[derive(Clone, Copy, Debug, PartialEq)]10pub struct Frame {11    /// The left edge in pixels.12    pub x: f64,13    /// The top edge in pixels.14    pub y: f64,15    /// The width in pixels.16    pub w: f64,17    /// The height in pixels.18    pub h: f64,19}2021impl Frame {22    /// Builds a frame from its corner and its size.23    pub fn new(x: f64, y: f64, w: f64, h: f64) -> Frame {24        Frame { x, y, w, h }25    }26    /// Shrinks the frame by the same number of pixels on every side.27    pub fn inset(&self, px: f64) -> Frame {28        Frame::new(29            self.x + px,30            self.y + px,31            self.w - 2.0 * px,32            self.h - 2.0 * px,33        )34    }35    /// Returns the width of one of n columns.36    pub fn cell(&self, n: usize) -> f64 {37        self.w / n as f6438    }39    /// Maps unit coordinates, zero at the top left and one at the bottom right, to pixels.40    pub fn at(&self, u: f64, v: f64) -> (f64, f64) {41        (self.x + u * self.w, self.y + v * self.h)42    }43    /// Returns the middle of the frame.44    pub fn center(&self) -> (f64, f64) {45        self.at(0.5, 0.5)46    }47    /// Returns the largest square centred inside the frame.48    pub fn square(&self) -> Frame {49        let side = self.w.min(self.h);50        Frame::new(51            self.x + (self.w - side) / 2.0,52            self.y + (self.h - side) / 2.0,53            side,54            side,55        )56    }57    /// Returns the shorter half-side, the radius a centred disc fills the frame with.58    pub fn radius(&self) -> f64 {59        self.w.min(self.h) / 2.060    }61    /// Splits the frame into n columns, left to right.62    pub fn cols(&self, n: usize) -> Vec<Frame> {63        let w = self.w / n as f64;64        (0..n)65            .map(|i| Frame::new(self.x + i as f64 * w, self.y, w, self.h))66            .collect()67    }68    /// Splits the frame into n rows, top to bottom.69    pub fn rows(&self, n: usize) -> Vec<Frame> {70        let h = self.h / n as f64;71        (0..n)72            .map(|i| Frame::new(self.x, self.y + i as f64 * h, self.w, h))73            .collect()74    }75}7677// GEOMETRY7879fn box_sdf(px: f64, py: f64, cx: f64, cy: f64, hw: f64, hh: f64) -> f64 {80    let qx = (px - cx).abs() - hw;81    let qy = (py - cy).abs() - hh;82    let outside = (qx.max(0.0).powi(2) + qy.max(0.0).powi(2)).sqrt();83    outside + qx.max(qy).min(0.0)84}8586fn segment_sdf(px: f64, py: f64, a: (f64, f64), b: (f64, f64)) -> f64 {87    let (vx, vy) = (b.0 - a.0, b.1 - a.1);88    let (wx, wy) = (px - a.0, py - a.1);89    let len = vx * vx + vy * vy;90    let t = if len <= f64::EPSILON {91        0.092    } else {93        ((wx * vx + wy * vy) / len).clamp(0.0, 1.0)94    };95    ((wx - t * vx).powi(2) + (wy - t * vy).powi(2)).sqrt()96}9798fn polygon_sdf(px: f64, py: f64, pts: &[(f64, f64)]) -> f64 {99    let mut dist = f64::MAX;100    let mut inside = false;101    for i in 0..pts.len() {102        let a = pts[i];103        let b = pts[(i + 1) % pts.len()];104        dist = dist.min(segment_sdf(px, py, a, b));105        if (a.1 > py) != (b.1 > py) && px < a.0 + (py - a.1) / (b.1 - a.1) * (b.0 - a.0) {106            inside = !inside;107        }108    }109    if inside {110        -dist111    } else {112        dist113    }114}115116fn bounds(pts: &[(f64, f64)]) -> (f64, f64, f64, f64) {117    let mut b = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);118    for p in pts {119        b.0 = b.0.min(p.0);120        b.1 = b.1.min(p.1);121        b.2 = b.2.max(p.0);122        b.3 = b.3.max(p.1);123    }124    b125}126127// BOARD128129/// The rgba canvas a figure is drawn on, row-major from the top left.130#[derive(Clone, Debug, PartialEq, Eq)]131pub struct Board {132    /// The width in pixels.133    pub width: usize,134    /// The height in pixels.135    pub height: usize,136    /// The rgba pixels, one per point of the raster.137    pub pixels: Vec<[u8; 4]>,138}139140impl Board {141    /// Builds a board of the given size flooded with the ground color.142    pub fn new(width: usize, height: usize, ground: Color) -> Board {143        Board {144            width,145            height,146            pixels: vec![[ground.r, ground.g, ground.b, ground.a]; width * height],147        }148    }149    /// The house figure: 1024 by 1024 on the ground of the theme in press.150    pub fn square() -> Board {151        Board::new(1024, 1024, ink::ground())152    }153    /// The social card: 1200 by 630 on the ground of the theme in press.154    pub fn og() -> Board {155        Board::new(1200, 630, ink::ground())156    }157    /// Returns the largest centred square left after a margin of the given fraction of the short side.158    pub fn frame(&self, margin: f64) -> Frame {159        let side = self.width.min(self.height) as f64 * (1.0 - 2.0 * margin);160        Frame::new(161            (self.width as f64 - side) / 2.0,162            (self.height as f64 - side) / 2.0,163            side,164            side,165        )166    }167    /// Returns the whole board inset by a margin of the given fraction of the short side.168    pub fn area(&self, margin: f64) -> Frame {169        let pad = self.width.min(self.height) as f64 * margin;170        Frame::new(0.0, 0.0, self.width as f64, self.height as f64).inset(pad)171    }172    /// Composites one color over one pixel at the given coverage.173    pub fn blend(&mut self, x: usize, y: usize, c: Color, cover: f64) {174        if x >= self.width || y >= self.height {175            return;176        }177        let a = (c.a as f64 / 255.0) * cover.clamp(0.0, 1.0);178        if a <= 0.0 {179            return;180        }181        let i = y * self.width + x;182        let d = self.pixels[i];183        let over = |s: u8, under: u8| (s as f64 * a + under as f64 * (1.0 - a)).round() as u8;184        let alpha = a + (d[3] as f64 / 255.0) * (1.0 - a);185        self.pixels[i] = [186            over(c.r, d[0]),187            over(c.g, d[1]),188            over(c.b, d[2]),189            (alpha * 255.0).round() as u8,190        ];191    }192193    fn shade(&mut self, area: (f64, f64, f64, f64), c: Color, sdf: impl Fn(f64, f64) -> f64) {194        let x0 = (area.0 - 1.0).floor().max(0.0) as usize;195        let y0 = (area.1 - 1.0).floor().max(0.0) as usize;196        let x1 = (area.2 + 1.0).ceil().max(0.0) as usize;197        let y1 = (area.3 + 1.0).ceil().max(0.0) as usize;198        for py in y0..y1.min(self.height) {199            for px in x0..x1.min(self.width) {200                let d = sdf(px as f64 + 0.5, py as f64 + 0.5);201                self.blend(px, py, c, 0.5 - d);202            }203        }204    }205206    /// Fills an axis-aligned rectangle.207    pub fn rect(&mut self, x: f64, y: f64, w: f64, h: f64, c: Color) {208        let (cx, cy) = (x + w / 2.0, y + h / 2.0);209        let (hw, hh) = (w / 2.0, h / 2.0);210        self.shade((x, y, x + w, y + h), c, |px, py| {211            box_sdf(px, py, cx, cy, hw, hh)212        });213    }214    /// Fills a rectangle with rounded corners of the given radius.215    pub fn round_rect(&mut self, x: f64, y: f64, w: f64, h: f64, r: f64, c: Color) {216        let (cx, cy) = (x + w / 2.0, y + h / 2.0);217        let r = r.min(w / 2.0).min(h / 2.0).max(0.0);218        let (hw, hh) = (w / 2.0 - r, h / 2.0 - r);219        self.shade((x, y, x + w, y + h), c, |px, py| {220            box_sdf(px, py, cx, cy, hw, hh) - r221        });222    }223    /// Fills a disc.224    pub fn disc(&mut self, cx: f64, cy: f64, r: f64, c: Color) {225        self.shade((cx - r, cy - r, cx + r, cy + r), c, |px, py| {226            ((px - cx).powi(2) + (py - cy).powi(2)).sqrt() - r227        });228    }229    /// Strokes a circle of the given radius, the stroke centred on it.230    pub fn ring(&mut self, cx: f64, cy: f64, r: f64, thick: f64, c: Color) {231        let outer = r + thick / 2.0;232        self.shade(233            (cx - outer, cy - outer, cx + outer, cy + outer),234            c,235            |px, py| (((px - cx).powi(2) + (py - cy).powi(2)).sqrt() - r).abs() - thick / 2.0,236        );237    }238    /// Strokes a straight run between two points, with round caps.239    pub fn segment(&mut self, a: (f64, f64), b: (f64, f64), thick: f64, c: Color) {240        let half = thick / 2.0;241        let (bx0, by0, bx1, by1) = bounds(&[a, b]);242        self.shade(243            (bx0 - half, by0 - half, bx1 + half, by1 + half),244            c,245            |px, py| segment_sdf(px, py, a, b) - half,246        );247    }248    /// Strokes a chain of points as one stroke, with round caps and joints.249    pub fn polyline(&mut self, pts: &[(f64, f64)], thick: f64, c: Color) {250        if pts.len() < 2 {251            return;252        }253        let half = thick / 2.0;254        let pad = half + 1.0;255        let (bx0, by0, bx1, by1) = bounds(pts);256        let x0 = (bx0 - pad).floor().max(0.0) as usize;257        let y0 = (by0 - pad).floor().max(0.0) as usize;258        let x1 = ((bx1 + pad).ceil().max(0.0) as usize).min(self.width);259        let y1 = ((by1 + pad).ceil().max(0.0) as usize).min(self.height);260        if x1 <= x0 || y1 <= y0 {261            return;262        }263        let span = x1 - x0;264        let mut mask = vec![0.0f64; span * (y1 - y0)];265        for pair in pts.windows(2) {266            let (a, b) = (pair[0], pair[1]);267            let (sx0, sy0, sx1, sy1) = bounds(&[a, b]);268            let sx0 = (sx0 - pad).floor().max(x0 as f64) as usize;269            let sy0 = (sy0 - pad).floor().max(y0 as f64) as usize;270            let sx1 = ((sx1 + pad).ceil().max(0.0) as usize).min(x1);271            let sy1 = ((sy1 + pad).ceil().max(0.0) as usize).min(y1);272            for py in sy0..sy1 {273                let row = (py - y0) * span;274                for px in sx0..sx1 {275                    let d = segment_sdf(px as f64 + 0.5, py as f64 + 0.5, a, b) - half;276                    let cover = (0.5 - d).clamp(0.0, 1.0);277                    let slot = &mut mask[row + (px - x0)];278                    if cover > *slot {279                        *slot = cover;280                    }281                }282            }283        }284        for py in y0..y1 {285            let row = (py - y0) * span;286            for px in x0..x1 {287                let cover = mask[row + (px - x0)];288                if cover > 0.0 {289                    self.blend(px, py, c, cover);290                }291            }292        }293    }294    /// Fills a triangle.295    pub fn triangle(&mut self, a: (f64, f64), b: (f64, f64), c: (f64, f64), color: Color) {296        self.polygon(&[a, b, c], color);297    }298    /// Fills any simple polygon, its inside decided by the even-odd rule.299    pub fn polygon(&mut self, pts: &[(f64, f64)], c: Color) {300        if pts.len() < 3 {301            return;302        }303        let (bx0, by0, bx1, by1) = bounds(pts);304        self.shade((bx0, by0, bx1, by1), c, |px, py| polygon_sdf(px, py, pts));305    }306    /// Strokes the arc of a circle about a centre between two angles in radians, clockwise on the screen.307    pub fn arc(&mut self, center: (f64, f64), r: f64, angles: (f64, f64), thick: f64, c: Color) {308        let (cx, cy) = center;309        let (from, to) = angles;310        let half = thick / 2.0;311        let outer = r + half;312        let span = (to - from).abs();313        let (lo, hi) = if to >= from { (from, to) } else { (to, from) };314        let ends = [315            (cx + r * lo.cos(), cy + r * lo.sin()),316            (cx + r * hi.cos(), cy + r * hi.sin()),317        ];318        self.shade(319            (cx - outer, cy - outer, cx + outer, cy + outer),320            c,321            |px, py| {322                let angle = (py - cy).atan2(px - cx);323                let mut turn = angle - lo;324                while turn < 0.0 {325                    turn += std::f64::consts::TAU;326                }327                if turn <= span.min(std::f64::consts::TAU) {328                    (((px - cx).powi(2) + (py - cy).powi(2)).sqrt() - r).abs() - half329                } else {330                    let d = ends331                        .iter()332                        .map(|e| ((px - e.0).powi(2) + (py - e.1).powi(2)).sqrt())333                        .fold(f64::MAX, f64::min);334                    d - half335                }336            },337        );338    }339    /// Encodes the board as a png at one pixel per point.340    pub fn png(&self) -> Result<Vec<u8>> {341        codec::png(&self.pixels, self.width, self.height, 1)342    }343}344345#[cfg(test)]346mod tests {347    use super::*;348    #[test]349    fn frame_cells_tile_the_frame_exactly() {350        let frame = Board::square().frame(0.08);351        assert!((frame.cell(81) * 81.0 - frame.w).abs() < 1e-9);352    }353    #[test]354    fn a_disc_covers_its_own_area() {355        let mut board = Board::new(256, 256, ink::ground());356        board.disc(128.0, 128.0, 90.0, ink::fg());357        let (ground, fg) = (ink::ground().r as f64, ink::fg().r as f64);358        let lit: f64 = board359            .pixels360            .iter()361            .map(|p| (p[0] as f64 - ground) / (fg - ground))362            .sum();363        let want = std::f64::consts::PI * 90.0 * 90.0;364        assert!(365            (lit - want).abs() / want < 0.02,366            "covered {lit}, want {want}"367        );368    }369    #[test]370    fn a_polyline_covers_its_stroke_area() {371        let mut board = Board::new(512, 512, ink::ground());372        let thick = 6.0;373        let pts: Vec<(f64, f64)> = (0..1000)374            .map(|i| {375                let x = 6.0 + 500.0 * i as f64 / 999.0;376                (x, 256.0 + 100.0 * (std::f64::consts::TAU * x / 250.0).sin())377            })378            .collect();379        board.polyline(&pts, thick, ink::fg());380        let length: f64 = pts381            .windows(2)382            .map(|p| ((p[1].0 - p[0].0).powi(2) + (p[1].1 - p[0].1).powi(2)).sqrt())383            .sum();384        let (ground, fg) = (ink::ground().r as f64, ink::fg().r as f64);385        let lit: f64 = board386            .pixels387            .iter()388            .map(|p| (p[0] as f64 - ground) / (fg - ground))389            .sum();390        let want = length * thick + std::f64::consts::PI * (thick / 2.0).powi(2);391        assert!(392            (lit - want).abs() / want < 0.03,393            "covered {lit}, want {want}"394        );395    }396    #[test]397    fn a_two_point_polyline_is_a_segment() {398        let (a, b) = ((17.3, 40.9), (190.7, 123.4));399        let mut one = Board::new(256, 192, ink::ground());400        one.segment(a, b, 7.0, ink::fg());401        let mut two = Board::new(256, 192, ink::ground());402        two.polyline(&[a, b], 7.0, ink::fg());403        assert_eq!(one.pixels, two.pixels);404    }405    #[test]406    fn the_og_board_is_the_social_card_size() {407        let board = Board::og();408        assert_eq!((board.width, board.height), (1200, 630));409    }410}