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}