1use crate::mark::{Lattice, Rect};
11
12pub const GROUND: [u8; 3] = [0x05, 0x10, 0x0a];
14pub const ACCENT: [u8; 3] = [0x58, 0xe0, 0x7a];
16
17#[derive(Clone, Copy, PartialEq, Eq, Debug)]
20pub enum Cut {
21 Square,
23 Round,
25}
26
27pub 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
57fn 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 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
79pub 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 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 #[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}