Skip to main content

didbot_brand/
paint.rs

1//! Turning a finished lattice into files: PNG for the raster sizes, SVG
2//! for the ones that scale.
3//!
4//! Two rules hold every image here together. Sites are painted as whole
5//! blocks at an integer scale, never resampled — a brand image is the
6//! lattice, magnified, not a photograph of one — and the two colours are
7//! the site's own (`site/src/styles/global.css`'s `--ground` and
8//! `--accent`, the same pair the landing page's canvas fills with).
9
10use crate::mark::{Lattice, Rect};
11
12/// `--ground`: the unlit site, and the background.
13pub const GROUND: [u8; 3] = [0x05, 0x10, 0x0a];
14/// `--accent`: the lit site.
15pub const ACCENT: [u8; 3] = [0x58, 0xe0, 0x7a];
16
17/// How a square image is cut: left whole, or cut to the disc inscribed in
18/// it (transparent outside, for an icon that will be shown round).
19#[derive(Clone, Copy, PartialEq, Eq, Debug)]
20pub enum Cut {
21    /// The whole tile, opaque to its corners.
22    Square,
23    /// The inscribed disc, transparent outside it.
24    Round,
25}
26
27/// Paints `rect` of `lattice` at `scale` pixels a site, as PNG bytes.
28///
29/// [`Cut::Square`] gives an opaque RGB image; [`Cut::Round`] an RGBA one
30/// whose corners are transparent, with the edge of the disc antialiased
31/// against the transparency (the one place in this crate that draws
32/// something the simulation did not, because a hard-stepped circle at 64
33/// pixels looks broken in a way a lattice does not).
34pub fn png(lattice: &Lattice, rect: Rect, scale: usize, cut: Cut) -> Vec<u8> {
35    let width = rect.width * scale;
36    let height = rect.height * scale;
37    match cut {
38        Cut::Square => {
39            let mut pixels = Vec::with_capacity(width * height * 3);
40            for_each_pixel(lattice, rect, scale, |lit, _| {
41                pixels.extend_from_slice(if lit { &ACCENT } else { &GROUND });
42            });
43            didbot_avatar::png::encode_rgb(width as u32, height as u32, &pixels)
44        }
45        Cut::Round => {
46            let mut pixels = Vec::with_capacity(width * height * 4);
47            for_each_pixel(lattice, rect, scale, |lit, coverage| {
48                let colour = if lit { ACCENT } else { GROUND };
49                pixels.extend_from_slice(&colour);
50                pixels.push((coverage * 255.0).round() as u8);
51            });
52            didbot_avatar::png::encode_rgba(width as u32, height as u32, &pixels)
53        }
54    }
55}
56
57/// Walks the output pixels in row-major order, handing each one the site
58/// it lands in and how much of it the disc covers (always `1.0` away from
59/// a round cut's edge).
60fn for_each_pixel(lattice: &Lattice, rect: Rect, scale: usize, mut pixel: impl FnMut(bool, f32)) {
61    let width = rect.width * scale;
62    let height = rect.height * scale;
63    let centre = (width as f32 / 2.0, height as f32 / 2.0);
64    let radius = (width.min(height) as f32) / 2.0;
65    for y in 0..height {
66        for x in 0..width {
67            let lit = lattice.lit_at(rect.x + x / scale, rect.y + y / scale);
68            // Distance of the pixel's centre from the disc's edge, in
69            // pixels, clamped into a one-pixel ramp: the cheapest
70            // antialiasing there is, and enough at these sizes.
71            let dx = x as f32 + 0.5 - centre.0;
72            let dy = y as f32 + 0.5 - centre.1;
73            let coverage = (radius - (dx * dx + dy * dy).sqrt() + 0.5).clamp(0.0, 1.0);
74            pixel(lit, coverage);
75        }
76    }
77}
78
79/// The same picture as an SVG: one `<rect>` a run of lit sites, on a
80/// ground-coloured field, at one unit a site.
81///
82/// Runs rather than one rect a site for the same reason the landing page's
83/// canvas draws runs — a 220x160 lattice is 35,200 sites and perhaps a
84/// tenth as many runs.
85pub fn svg(lattice: &Lattice, rect: Rect, cut: Cut) -> String {
86    let mut out = String::new();
87    out.push_str(&format!(
88        "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {} {}\" \
89         shape-rendering=\"crispEdges\">\n",
90        rect.width, rect.height
91    ));
92    let clip = match cut {
93        Cut::Square => {
94            out.push_str(&format!(
95                "  <rect width=\"{}\" height=\"{}\" fill=\"#{}\"/>\n",
96                rect.width,
97                rect.height,
98                hex(GROUND)
99            ));
100            String::new()
101        }
102        Cut::Round => {
103            let r = rect.width.min(rect.height) as f32 / 2.0;
104            out.push_str(&format!(
105                "  <circle cx=\"{:.1}\" cy=\"{:.1}\" r=\"{r:.1}\" fill=\"#{}\"/>\n",
106                rect.width as f32 / 2.0,
107                rect.height as f32 / 2.0,
108                hex(GROUND)
109            ));
110            out.push_str(&format!(
111                "  <clipPath id=\"disc\"><circle cx=\"{:.1}\" cy=\"{:.1}\" r=\"{r:.1}\"/></clipPath>\n",
112                rect.width as f32 / 2.0,
113                rect.height as f32 / 2.0,
114            ));
115            " clip-path=\"url(#disc)\"".to_string()
116        }
117    };
118    out.push_str(&format!("  <g fill=\"#{}\"{clip}>\n", hex(ACCENT)));
119    for y in 0..rect.height {
120        let mut run: Option<usize> = None;
121        for x in 0..=rect.width {
122            let lit = x < rect.width && lattice.lit_at(rect.x + x, rect.y + y);
123            match (lit, run) {
124                (true, None) => run = Some(x),
125                (false, Some(start)) => {
126                    out.push_str(&format!(
127                        "    <rect x=\"{start}\" y=\"{y}\" width=\"{}\" height=\"1\"/>\n",
128                        x - start
129                    ));
130                    run = None;
131                }
132                _ => {}
133            }
134        }
135    }
136    out.push_str("  </g>\n</svg>\n");
137    out
138}
139
140fn hex(colour: [u8; 3]) -> String {
141    format!("{:02x}{:02x}{:02x}", colour[0], colour[1], colour[2])
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    fn checkerboard() -> Lattice {
149        Lattice {
150            width: 4,
151            height: 4,
152            lit: (0..16).map(|i| i % 3 == 0).collect(),
153        }
154    }
155
156    fn whole(lattice: &Lattice) -> Rect {
157        Rect {
158            x: 0,
159            y: 0,
160            width: lattice.width,
161            height: lattice.height,
162        }
163    }
164
165    #[test]
166    fn a_square_png_is_an_opaque_png_of_the_asked_for_size() {
167        let lattice = checkerboard();
168        let bytes = png(&lattice, whole(&lattice), 8, Cut::Square);
169        assert_eq!(&bytes[1..4], b"PNG");
170        // IHDR: width, height, then the colour-type byte. 2 is truecolour.
171        assert_eq!(&bytes[16..24], &[0, 0, 0, 32, 0, 0, 0, 32]);
172        assert_eq!(bytes[25], 2);
173    }
174
175    #[test]
176    fn a_round_png_carries_alpha_and_is_transparent_in_its_corners() {
177        let lattice = checkerboard();
178        let bytes = png(&lattice, whole(&lattice), 8, Cut::Round);
179        assert_eq!(bytes[25], 6, "colour type 6 is truecolour with alpha");
180    }
181
182    /// The corner of a round cut has to actually be cut, which the PNG
183    /// bytes above cannot show through the compression — so check the
184    /// coverage the painter computes instead.
185    #[test]
186    fn the_round_cut_covers_the_centre_and_not_the_corners() {
187        let lattice = checkerboard();
188        let mut coverage = Vec::new();
189        for_each_pixel(&lattice, whole(&lattice), 8, |_, alpha| {
190            coverage.push(alpha)
191        });
192        assert_eq!(coverage[0], 0.0, "top-left corner");
193        assert_eq!(coverage[32 * 16 + 16], 1.0, "the centre");
194    }
195
196    #[test]
197    fn the_svg_merges_a_row_into_one_rect_a_run() {
198        let lattice = Lattice {
199            width: 4,
200            height: 1,
201            lit: vec![true, true, true, false],
202        };
203        let out = svg(&lattice, whole(&lattice), Cut::Square);
204        assert_eq!(out.matches("<rect").count(), 2, "the ground, and one run");
205        assert!(out.contains("x=\"0\" y=\"0\" width=\"3\""), "{out}");
206    }
207}