pikadick/commands/tic_tac_toe/
renderer.rs

1use anyhow::Context;
2use once_cell::sync::Lazy;
3use std::{
4    sync::Arc,
5    time::Instant,
6};
7use tiny_skia::{
8    Paint,
9    Path,
10    PathBuilder,
11    Pixmap,
12    Rect,
13    Stroke,
14    Transform,
15};
16use tokio::sync::Semaphore;
17use tracing::info;
18use ttf_parser::OutlineBuilder;
19
20const FONT_BYTES: &[u8] =
21    include_bytes!("../../../assets/Averia_Serif_Libre/AveriaSerifLibre-Light.ttf");
22static FONT_FACE: Lazy<ttf_parser::Face<'static>> =
23    Lazy::new(|| ttf_parser::Face::parse(FONT_BYTES, 0).expect("failed to load `FONT_BYTES`"));
24
25const RENDERED_SIZE: u16 = 300;
26const SQUARE_SIZE: u16 = RENDERED_SIZE / 3;
27const SQUARE_SIZE_USIZE: usize = SQUARE_SIZE as usize;
28const SQUARE_SIZE_F32: f32 = SQUARE_SIZE as f32;
29const HALF_SQUARE_SIZE_F32: f32 = SQUARE_SIZE_F32 / 2.0;
30
31const MAX_PARALLEL_RENDER_LIMIT: usize = 4;
32
33/// Render a Tic-Tac-Toe board
34#[derive(Debug, Clone)]
35pub(crate) struct Renderer {
36    background_pixmap: Arc<Pixmap>,
37    number_paths: Arc<[Path]>,
38
39    render_semaphore: Arc<Semaphore>,
40}
41
42#[allow(clippy::new_without_default)]
43impl Renderer {
44    /// Make a new [`Renderer`].
45    pub(crate) fn new() -> anyhow::Result<Self> {
46        let mut background_pixmap = Pixmap::new(RENDERED_SIZE.into(), RENDERED_SIZE.into())
47            .context("failed to create background pixmap")?;
48
49        let mut paint = Paint::default();
50        for i in 0..3 {
51            for j in 0..3 {
52                let x = i * SQUARE_SIZE;
53                let y = j * SQUARE_SIZE;
54                let square =
55                    Rect::from_xywh(f32::from(x), f32::from(y), SQUARE_SIZE_F32, SQUARE_SIZE_F32)
56                        .context("failed to make square")?;
57
58                if (j * 3 + i) % 2 == 0 {
59                    paint.set_color_rgba8(255, 0, 0, 255);
60                } else {
61                    paint.set_color_rgba8(119, 119, 119, 255);
62                }
63
64                background_pixmap.fill_rect(square, &paint, Transform::identity(), None);
65            }
66        }
67
68        let mut number_paths = Vec::with_capacity(10);
69        let mut paint = Paint::default();
70        paint.set_color_rgba8(255, 255, 255, 255);
71        for i in b'0'..=b'9' {
72            let glyph_id = FONT_FACE
73                .glyph_index(char::from(i))
74                .with_context(|| format!("missing glyph for '{}'", char::from(i)))?;
75
76            let mut builder = SkiaBuilder::new();
77            let _bb = FONT_FACE
78                .outline_glyph(glyph_id, &mut builder)
79                .with_context(|| format!("missing glyph bounds for '{}'", char::from(i)))?;
80            let path = builder.into_path().with_context(|| {
81                format!("failed to generate glyph path for '{}'", char::from(i))
82            })?;
83
84            number_paths.push(path);
85        }
86
87        Ok(Self {
88            background_pixmap: Arc::new(background_pixmap),
89            number_paths: Arc::from(number_paths),
90            render_semaphore: Arc::new(Semaphore::new(MAX_PARALLEL_RENDER_LIMIT)),
91        })
92    }
93
94    /// Render a Tic-Tac-Toe board with `tiny_skia`.
95    // Author might add more fields
96    #[allow(clippy::field_reassign_with_default)]
97    pub(crate) fn render_board(&self, board: tic_tac_toe::Board) -> anyhow::Result<Vec<u8>> {
98        const PIECE_WIDTH: u16 = 4;
99
100        let draw_start = Instant::now();
101        let mut pixmap = self.background_pixmap.as_ref().as_ref().to_owned();
102
103        let mut paint = Paint::default();
104        let mut stroke = Stroke::default();
105        paint.anti_alias = true;
106        stroke.width = f32::from(PIECE_WIDTH);
107
108        for (i, team) in board.iter() {
109            let transform = Transform::from_translate(
110                f32::from((u16::from(i) % 3) * SQUARE_SIZE),
111                f32::from((u16::from(i) / 3) * SQUARE_SIZE),
112            );
113
114            if let Some(team) = team {
115                paint.set_color_rgba8(0, 0, 0, 255);
116                let path = match team {
117                    tic_tac_toe::Team::X => {
118                        let mut path_builder = PathBuilder::new();
119                        path_builder.move_to((PIECE_WIDTH / 2).into(), (PIECE_WIDTH / 2).into());
120                        path_builder.line_to(
121                            SQUARE_SIZE_F32 - f32::from(PIECE_WIDTH / 2),
122                            SQUARE_SIZE_F32 - f32::from(PIECE_WIDTH / 2),
123                        );
124                        path_builder.move_to(
125                            (PIECE_WIDTH / 2).into(),
126                            SQUARE_SIZE_F32 - f32::from(PIECE_WIDTH / 2),
127                        );
128                        path_builder.line_to(
129                            SQUARE_SIZE_F32 - f32::from(PIECE_WIDTH / 2),
130                            f32::from(PIECE_WIDTH / 2),
131                        );
132                        path_builder.finish()
133                    }
134                    tic_tac_toe::Team::O => PathBuilder::from_circle(
135                        HALF_SQUARE_SIZE_F32,
136                        HALF_SQUARE_SIZE_F32,
137                        HALF_SQUARE_SIZE_F32 - f32::from(PIECE_WIDTH / 2),
138                    ),
139                };
140                let path =
141                    path.with_context(|| format!("failed to build path for team '{:?}'", team))?;
142
143                pixmap.stroke_path(&path, &paint, &stroke, transform, None);
144            } else {
145                paint.set_color_rgba8(255, 255, 255, 255);
146                let path = &self.number_paths[usize::from(i) + 1];
147                let bounds = path.bounds();
148
149                let ratio = f32::from(SQUARE_SIZE / 2) / bounds.height().max(bounds.width());
150                let transform = transform.pre_scale(ratio, ratio).post_translate(
151                    (SQUARE_SIZE_F32 / 2.0) - (ratio * bounds.width() / 2.0),
152                    (SQUARE_SIZE_F32 / 2.0) - (ratio * bounds.height() / 2.0),
153                );
154
155                pixmap.fill_path(path, &paint, Default::default(), transform, None);
156            }
157        }
158
159        // Draw winning line
160        if let Some(winner_info) = board.get_winner_info() {
161            draw_winning_line(&mut pixmap, stroke, paint, winner_info)
162                .context("failed to draw winning line")?;
163        }
164
165        let draw_end = Instant::now();
166        info!("board draw time: {:?}", draw_end - draw_start);
167
168        let encode_start = Instant::now();
169        let img = pixmap.encode_png().context("failed to encode board")?;
170        let encode_end = Instant::now();
171
172        info!("board png encode time: {:?}", encode_end - encode_start);
173
174        Ok(img)
175    }
176
177    /// Render a Tic-Tac-Toe board on a threadpool
178    pub(crate) async fn render_board_async(
179        &self,
180        board: tic_tac_toe::Board,
181    ) -> anyhow::Result<Vec<u8>> {
182        // TODO: LRU cache
183        let _permit = self.render_semaphore.acquire().await?;
184        let self_clone = self.clone();
185        tokio::task::spawn_blocking(move || self_clone.render_board(board)).await?
186    }
187}
188
189/// Draw the winning line
190fn draw_winning_line(
191    pixmap: &mut Pixmap,
192    mut stroke: Stroke,
193    mut paint: Paint<'_>,
194    winner_info: tic_tac_toe::WinnerInfo,
195) -> anyhow::Result<()> {
196    stroke.width = 10.0;
197    paint.set_color_rgba8(48, 48, 48, 255);
198
199    let start_index = winner_info.start_tile_index();
200    let start = usize::from(start_index);
201    let mut start_x = ((start % 3) * SQUARE_SIZE_USIZE + (SQUARE_SIZE_USIZE / 2)) as f32;
202    let mut start_y = ((start / 3) * SQUARE_SIZE_USIZE + (SQUARE_SIZE_USIZE / 2)) as f32;
203
204    let end_index = winner_info.end_tile_index();
205    let end = usize::from(end_index);
206    let mut end_x = ((end % 3) * SQUARE_SIZE_USIZE + (SQUARE_SIZE_USIZE / 2)) as f32;
207    let mut end_y = ((end / 3) * SQUARE_SIZE_USIZE + (SQUARE_SIZE_USIZE / 2)) as f32;
208
209    match winner_info.win_type {
210        tic_tac_toe::WinType::Horizontal => {
211            start_x -= SQUARE_SIZE_F32 / 4.0;
212            end_x += SQUARE_SIZE_F32 / 4.0;
213        }
214        tic_tac_toe::WinType::Vertical => {
215            start_y -= SQUARE_SIZE_F32 / 4.0;
216            end_y += SQUARE_SIZE_F32 / 4.0;
217        }
218        tic_tac_toe::WinType::Diagonal => {
219            start_x -= SQUARE_SIZE_F32 / 4.0;
220            start_y -= SQUARE_SIZE_F32 / 4.0;
221            end_x += SQUARE_SIZE_F32 / 4.0;
222            end_y += SQUARE_SIZE_F32 / 4.0;
223        }
224        tic_tac_toe::WinType::AntiDiagonal => {
225            start_x += SQUARE_SIZE_F32 / 4.0;
226            start_y -= SQUARE_SIZE_F32 / 4.0;
227            end_x -= SQUARE_SIZE_F32 / 4.0;
228            end_y += SQUARE_SIZE_F32 / 4.0;
229        }
230    }
231
232    let mut path_builder = PathBuilder::new();
233    path_builder.move_to(start_x, start_y);
234    path_builder.line_to(end_x, end_y);
235    let path = path_builder
236        .finish()
237        .context("failed to draw winning line")?;
238
239    pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
240
241    Ok(())
242}
243
244/// Utility to draw a font glyph to a path.
245#[derive(Debug)]
246pub(crate) struct SkiaBuilder(PathBuilder);
247
248impl SkiaBuilder {
249    /// Make a new [`SkiaBuilder`].
250    pub(crate) fn new() -> Self {
251        Self(Default::default())
252    }
253
254    /// Get the inner [`tiny_skia::Path`].
255    pub(crate) fn into_path(self) -> Option<Path> {
256        let mut path = self.0.finish()?;
257
258        // This transform is needed to make ttf's coordinate system agree with tiny-skia's
259        let bounds = path.bounds();
260        let transform = Transform::from_scale(1.0, -1.0)
261            .post_translate(-bounds.x(), bounds.y() + bounds.height());
262        path = path.transform(transform)?;
263
264        Some(path)
265    }
266}
267
268impl Default for SkiaBuilder {
269    fn default() -> Self {
270        Self::new()
271    }
272}
273
274impl OutlineBuilder for SkiaBuilder {
275    fn move_to(&mut self, x: f32, y: f32) {
276        self.0.move_to(x, y);
277    }
278
279    fn line_to(&mut self, x: f32, y: f32) {
280        self.0.line_to(x, y);
281    }
282
283    fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
284        self.0.quad_to(x1, y1, x, y);
285    }
286
287    fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
288        // TODO: This is not used, is it implemented correctly?
289        self.0.cubic_to(x1, y1, x2, y2, x, y);
290    }
291
292    fn close(&mut self) {
293        self.0.close();
294    }
295}
296
297#[cfg(test)]
298mod test {
299    use super::*;
300    use tic_tac_toe::Team;
301
302    #[test]
303    fn render_board() {
304        let renderer = Renderer::new().expect("failed to make renderer");
305        let board = tic_tac_toe::Board::new()
306            .set(0, Some(Team::X))
307            .set(4, Some(Team::X))
308            .set(8, Some(Team::X));
309        let img = renderer.render_board(board).expect("failed to render");
310        std::fs::write("ttt-render-test.png", img).expect("failed to save");
311    }
312}