Skip to main content

retroglyph_ui/camera/
tile.rs

1//! Tile-to-screen projection for [`TileCamera`](super::TileCamera).
2
3use super::TileCamera;
4use retroglyph_core::grid::{Pos, Rect};
5use retroglyph_core::surface::Surface;
6
7impl TileCamera {
8    /// Iterate the visible tiles as `(tile, screen)` position pairs, in row-major order, where
9    /// `screen` is the tile's top-left screen cell. Only tiles that exist in the world are
10    /// yielded, so the caller can fill the rest of the viewport with a background. The tile
11    /// equivalent of [`Camera::cells`](crate::camera::Camera::cells): one entry per tile, not
12    /// per screen cell, so `cells().count()` is a tile count even at a zoom where each tile
13    /// covers several screen cells.
14    #[must_use = "iterators are lazy and do nothing unless consumed"]
15    pub fn cells(&self) -> impl Iterator<Item = (Pos, Pos)> {
16        let viewport = self.viewport;
17        let origin = self.tiles.origin();
18        let zoom = self.zoom;
19        self.tiles.cells().map(move |(tile, _)| {
20            let dx = (tile.x - origin.x) * zoom.width;
21            let dy = (tile.y - origin.y) * zoom.height;
22            let screen = Pos::new(
23                viewport.left().saturating_add(dx),
24                viewport.top().saturating_add(dy),
25            );
26            (tile, screen)
27        })
28    }
29
30    /// Map a screen position back to a tile position, or `None` if it is outside the viewport
31    /// or beyond the world (useful for mouse picking at a non-default zoom). The tile
32    /// equivalent of [`Camera::screen_to_world`](crate::camera::Camera::screen_to_world): where
33    /// that divides by `1`, this divides by [`zoom`](Self::zoom), so it agrees with
34    /// [`tile_rect`](Self::tile_rect) about which tile a screen cell belongs to.
35    ///
36    /// # Examples
37    ///
38    /// ```
39    /// use retroglyph_core::grid::{Pos, Rect, Size};
40    /// use retroglyph_ui::camera::TileCamera;
41    ///
42    /// let cam = TileCamera::new(Rect::new(0, 0, 12, 8), Size::new(20, 20), Size::new(3, 2));
43    /// // Any screen cell inside tile (1, 0)'s 3x2 footprint maps back to (1, 0).
44    /// assert_eq!(cam.screen_to_tile(Pos::new(4, 1)), Some(Pos::new(1, 0)));
45    /// ```
46    #[must_use]
47    pub fn screen_to_tile(&self, screen: Pos) -> Option<Pos> {
48        if !self.viewport.contains_pos(screen) {
49            return None;
50        }
51        let origin = self.tiles.origin();
52        let tx = origin.x + (screen.x - self.viewport.left()) / self.zoom.width;
53        let ty = origin.y + (screen.y - self.viewport.top()) / self.zoom.height;
54        let world = self.tiles.world();
55        if tx >= world.width || ty >= world.height {
56            return None;
57        }
58        Some(Pos::new(tx, ty))
59    }
60
61    /// The absolute screen rect a tile's footprint covers, and (in `i32`, so it can go negative
62    /// or past `u16::MAX`) that footprint's ideal, unclipped top-left -- the ongoing reference
63    /// point every screen cell inside the tile is measured from, even once the visible rect
64    /// below has been clipped down to a smaller slice of it.
65    fn tile_geometry(&self, tile: Pos) -> (Rect, (i32, i32)) {
66        let zoom_w = i64::from(self.zoom.width);
67        let zoom_h = i64::from(self.zoom.height);
68        let origin = self.tiles.origin();
69        let ox =
70            i64::from(self.viewport.left()) + (i64::from(tile.x) - i64::from(origin.x)) * zoom_w;
71        let oy =
72            i64::from(self.viewport.top()) + (i64::from(tile.y) - i64::from(origin.y)) * zoom_h;
73
74        let ideal = ixy::Rect::<i64>::new(ox, oy, zoom_w, zoom_h);
75        let bounds = ixy::Rect::<i64>::new(
76            i64::from(self.viewport.left()),
77            i64::from(self.viewport.top()),
78            i64::from(self.viewport.width()),
79            i64::from(self.viewport.height()),
80        );
81        let clipped = ideal.intersect(bounds);
82
83        let left = u16::try_from(clipped.left()).unwrap_or(u16::MAX);
84        let top = u16::try_from(clipped.top()).unwrap_or(u16::MAX);
85        let width = u16::try_from(clipped.width()).unwrap_or(0);
86        let height = u16::try_from(clipped.height()).unwrap_or(0);
87
88        let ox = i32::try_from(ox).unwrap_or(if ox < 0 { i32::MIN } else { i32::MAX });
89        let oy = i32::try_from(oy).unwrap_or(if oy < 0 { i32::MIN } else { i32::MAX });
90
91        (Rect::new(left, top, width, height), (ox, oy))
92    }
93
94    /// The absolute screen rect a tile's footprint covers at this camera's
95    /// [`zoom`](Self::zoom), clipped to the viewport. An empty `Rect` (zero width or height)
96    /// means no part of the tile is visible.
97    ///
98    /// `tile_rect` only clips to the viewport, not to [`world`](Self::world): a tile's existence
99    /// in the world is the caller's own concern.
100    ///
101    /// A tile straddling the viewport edge reports only the visible slice of its full,
102    /// ideal-sized footprint, not the full footprint or an empty `Rect`, matching how
103    /// [`Camera::surface`](crate::camera::Camera::surface) clips a multi-cell draw that's only
104    /// partially on screen instead of dropping it entirely.
105    ///
106    /// # Examples
107    ///
108    /// ```
109    /// use retroglyph_core::grid::{Pos, Rect, Size};
110    /// use retroglyph_ui::camera::TileCamera;
111    ///
112    /// let mut cam = TileCamera::new(Rect::new(0, 0, 10, 10), Size::new(20, 20), Size::new(3, 2));
113    ///
114    /// // tile (0, 0) is drawn at origin (0, 0) here, so its footprint covers a 3x2 block.
115    /// assert_eq!(cam.tile_rect(Pos::new(0, 0)), Rect::new(0, 0, 3, 2));
116    ///
117    /// // A tile straddling the viewport's right edge is clipped to what's actually visible.
118    /// let cam = TileCamera::new(Rect::new(0, 0, 4, 4), Size::new(20, 20), Size::new(3, 3));
119    /// assert_eq!(cam.tile_rect(Pos::new(1, 0)), Rect::new(3, 0, 1, 3));
120    /// ```
121    #[must_use]
122    pub fn tile_rect(&self, tile: Pos) -> Rect {
123        self.tile_geometry(tile).0
124    }
125
126    /// A view of `surface` scoped to one tile's footprint at this camera's
127    /// [`zoom`](Self::zoom), or `None` if none of that footprint is visible.
128    ///
129    /// The returned surface's own coordinate space is tile-local: `(0, 0)` is the tile's own
130    /// top-left corner, and `put`/`print`/the rest of `Surface`'s drawing methods take positions
131    /// in `0..zoom.width()` by `0..zoom.height()`, the same way a small standalone `Surface`
132    /// would for a `zoom.width() x zoom.height()` sprite. A tile straddling the viewport edge is
133    /// still clipped automatically: only the on-screen slice of it accepts writes.
134    ///
135    /// Calling this once per visible tile (for example every `(tile, _)` pair from
136    /// [`cells`](Self::cells)) replaces hand-rolled tile-range-plus-offset math with one
137    /// drawable region per tile.
138    ///
139    /// # Examples
140    ///
141    /// ```
142    /// use retroglyph_core::color::Style;
143    /// use retroglyph_core::grid::{Grid, Pos, Rect, Size};
144    /// use retroglyph_core::surface::Surface;
145    /// use retroglyph_ui::camera::TileCamera;
146    ///
147    /// let mut grid = Grid::new(10, 10);
148    /// let mut root = Surface::new(&mut grid, Rect::new(0, 0, 10, 10), 0);
149    ///
150    /// let cam = TileCamera::new(Rect::new(0, 0, 10, 10), Size::new(20, 20), Size::new(3, 2));
151    ///
152    /// // tile (1, 0)'s footprint covers screen cells (3, 0)..(6, 2); draw distinct glyphs at
153    /// // two corners of it in tile-local coordinates.
154    /// let mut tile = cam.tile_surface(Pos::new(1, 0), &mut root).expect("tile is visible");
155    /// tile.put(Pos::new(0, 0), '[', Style::default());
156    /// tile.put(Pos::new(2, 1), ']', Style::default());
157    ///
158    /// assert_eq!(grid[Pos::new(3, 0)].glyph(), '[');
159    /// assert_eq!(grid[Pos::new(5, 1)].glyph(), ']');
160    /// ```
161    ///
162    /// A tile straddling the viewport edge only accepts writes to its visible slice; the rest is
163    /// silently dropped, like any other out-of-clip `Surface` write.
164    ///
165    /// ```
166    /// use retroglyph_core::color::Style;
167    /// use retroglyph_core::grid::{Grid, Pos, Rect, Size};
168    /// use retroglyph_core::surface::Surface;
169    /// use retroglyph_ui::camera::TileCamera;
170    ///
171    /// let mut grid = Grid::new(10, 10);
172    /// let cam = TileCamera::new(Rect::new(0, 0, 4, 4), Size::new(20, 20), Size::new(3, 3));
173    ///
174    /// // This tile's ideal footprint is (3, 0, 3, 3); only its leftmost column (3, 0)..(4, 3)
175    /// // is inside the 4-wide viewport.
176    /// {
177    ///     let mut root = Surface::new(&mut grid, Rect::new(0, 0, 10, 10), 0);
178    ///     let mut tile = cam.tile_surface(Pos::new(1, 0), &mut root).expect("tile is visible");
179    ///     tile.put(Pos::new(0, 0), 'L', Style::default()); // the tile's own visible column.
180    ///     tile.put(Pos::new(2, 0), 'R', Style::default()); // clipped off past the viewport edge.
181    /// }
182    ///
183    /// assert_eq!(grid[Pos::new(3, 0)].glyph(), 'L');
184    /// assert_eq!(grid[Pos::new(5, 0)].glyph(), ' ');
185    ///
186    /// // A tile entirely outside the viewport has nothing visible to draw into.
187    /// let mut root = Surface::new(&mut grid, Rect::new(0, 0, 10, 10), 0);
188    /// assert!(cam.tile_surface(Pos::new(10, 10), &mut root).is_none());
189    /// ```
190    #[must_use]
191    pub fn tile_surface<'a>(&self, tile: Pos, surface: &'a mut Surface<'_>) -> Option<Surface<'a>> {
192        let (area, (ox, oy)) = self.tile_geometry(tile);
193        if area.is_empty() {
194            return None;
195        }
196        let origin_x = i32::from(area.left()).saturating_sub(ox);
197        let origin_y = i32::from(area.top()).saturating_sub(oy);
198        Some(surface.clip_translate(area, (origin_x, origin_y)))
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::super::tile_cam;
205    use super::*;
206    use retroglyph_core::grid::Size;
207
208    #[test]
209    fn tile_rect_matches_a_single_screen_cell_at_the_default_1x1_zoom() {
210        let mut c = tile_cam();
211        c.center_on(Pos::new(50, 50));
212        assert_eq!(c.tile_rect(Pos::new(50, 50)), Rect::new(5, 5, 1, 1));
213        assert_eq!(c.tile_rect(Pos::new(44, 50)), Rect::new(0, 0, 0, 0));
214    }
215
216    #[test]
217    fn tile_rect_covers_a_zoom_sized_block() {
218        let c = TileCamera::new(Rect::new(0, 0, 10, 10), Size::new(20, 20), Size::new(3, 2));
219        assert_eq!(c.tile_rect(Pos::new(0, 0)), Rect::new(0, 0, 3, 2));
220        assert_eq!(c.tile_rect(Pos::new(1, 0)), Rect::new(3, 0, 3, 2));
221    }
222
223    #[test]
224    fn tile_rect_clips_a_tile_straddling_the_viewport_edge() {
225        let c = TileCamera::new(Rect::new(0, 0, 4, 4), Size::new(20, 20), Size::new(3, 3));
226        // Ideal footprint (3, 0, 3, 3); only one column is inside the 4-wide viewport.
227        assert_eq!(c.tile_rect(Pos::new(1, 0)), Rect::new(3, 0, 1, 3));
228    }
229
230    #[test]
231    fn tile_rect_is_empty_for_a_tile_entirely_off_viewport() {
232        let c = TileCamera::new(Rect::new(0, 0, 4, 4), Size::new(20, 20), Size::new(1, 1));
233        assert_eq!(c.tile_rect(Pos::new(10, 10)), Rect::new(0, 0, 0, 0));
234    }
235
236    #[test]
237    fn set_zoom_clamps_a_zero_axis_to_one() {
238        let mut c = tile_cam();
239        c.set_zoom(Size::new(0, 0));
240        assert_eq!(c.zoom(), Size::new(1, 1));
241    }
242
243    #[test]
244    fn tile_surface_draws_within_a_single_tile_footprint() {
245        use retroglyph_core::color::Style;
246        use retroglyph_core::grid::Grid;
247
248        let mut grid = Grid::new(10, 10);
249        let mut root = Surface::new(&mut grid, Rect::new(0, 0, 10, 10), 0);
250
251        let c = TileCamera::new(Rect::new(0, 0, 10, 10), Size::new(20, 20), Size::new(3, 2));
252
253        let mut tile = c.tile_surface(Pos::new(1, 0), &mut root).expect("visible");
254        tile.put(Pos::new(0, 0), '[', Style::default());
255        tile.put(Pos::new(2, 1), ']', Style::default());
256        // Outside the tile's own 3x2 footprint: dropped, not drawn into the next tile over.
257        tile.put(Pos::new(3, 0), 'X', Style::default());
258
259        assert_eq!(grid[Pos::new(3, 0)].glyph(), '[');
260        assert_eq!(grid[Pos::new(5, 1)].glyph(), ']');
261        assert_eq!(grid[Pos::new(6, 0)].glyph(), ' ');
262    }
263
264    #[test]
265    fn tile_surface_clips_a_tile_straddling_the_viewport_edge() {
266        use retroglyph_core::color::Style;
267        use retroglyph_core::grid::Grid;
268
269        let mut grid = Grid::new(10, 10);
270        let c = TileCamera::new(Rect::new(0, 0, 4, 4), Size::new(20, 20), Size::new(3, 3));
271
272        {
273            let mut root = Surface::new(&mut grid, Rect::new(0, 0, 10, 10), 0);
274            let mut tile = c.tile_surface(Pos::new(1, 0), &mut root).expect("visible");
275            // Tile-local (0, 0) is the tile's own leftmost column, the only part on screen.
276            tile.put(Pos::new(0, 0), 'L', Style::default());
277            // Tile-local (2, 0) falls past the viewport's right edge: dropped.
278            tile.put(Pos::new(2, 0), 'R', Style::default());
279        }
280
281        assert_eq!(grid[Pos::new(3, 0)].glyph(), 'L');
282        assert_eq!(grid[Pos::new(5, 0)].glyph(), ' ');
283    }
284
285    #[test]
286    fn tile_surface_is_none_for_a_tile_entirely_off_viewport() {
287        use retroglyph_core::grid::Grid;
288
289        let mut grid = Grid::new(10, 10);
290        let mut root = Surface::new(&mut grid, Rect::new(0, 0, 10, 10), 0);
291        let c = TileCamera::new(Rect::new(0, 0, 4, 4), Size::new(20, 20), Size::new(1, 1));
292
293        assert!(c.tile_surface(Pos::new(10, 10), &mut root).is_none());
294    }
295
296    // The regression tests the issue asks for: `Camera` and `TileCamera` now hold entirely
297    // separate projections, so pan/cull/hit-test all agree with `tile_rect` at zoom != 1,
298    // instead of `Camera`'s old single struct silently disagreeing with itself.
299
300    #[test]
301    fn screen_to_tile_agrees_with_tile_rect_at_a_non_default_zoom() {
302        let mut c = TileCamera::new(Rect::new(0, 0, 12, 8), Size::new(20, 20), Size::new(3, 2));
303        c.center_on(Pos::new(10, 10));
304
305        let tile = Pos::new(10, 10);
306        let screen = c.tile_rect(tile).top_left();
307        // The two projections `TileCamera` offers (draw via `tile_rect`, hit-test via
308        // `screen_to_tile`) must round-trip: this is exactly what the old `Camera` with an
309        // internal `zoom` field could not guarantee, since `screen_to_world` ignored zoom while
310        // `tile_rect` did not.
311        assert_eq!(c.screen_to_tile(screen), Some(tile));
312    }
313
314    #[test]
315    fn scroll_by_reaches_the_last_tile_at_a_non_default_zoom() {
316        // A 10x10 screen viewport at 4x4 zoom is 2x2 tiles; scrolled to the far edge of a 15x15
317        // tile world, the max origin is `world - viewport_tiles` = 15 - 2 = 13, the same clamp
318        // `Camera::scroll_by` applies, just paced in tiles instead of screen cells.
319        let mut c = TileCamera::new(Rect::new(0, 0, 10, 10), Size::new(15, 15), Size::new(4, 4));
320        c.scroll_by(1000, 1000);
321        assert_eq!(c.origin(), Pos::new(13, 13));
322        // The world's last tile (index 14) is reachable and its footprint is still fully on
323        // screen: one tile past the origin, at pixel offset 4.
324        let last = c.tile_rect(Pos::new(14, 14));
325        assert_eq!(last, Rect::new(4, 4, 4, 4));
326    }
327
328    #[test]
329    fn center_on_puts_the_target_tiles_footprint_inside_the_viewport_at_a_non_default_zoom() {
330        let mut c = TileCamera::new(Rect::new(0, 0, 12, 8), Size::new(20, 20), Size::new(3, 2));
331        c.center_on(Pos::new(10, 10));
332        let footprint = c.tile_rect(Pos::new(10, 10));
333        assert!(!footprint.is_empty());
334        assert!(c.viewport().contains_rect(footprint));
335    }
336
337    #[test]
338    fn cells_count_matches_the_tile_count_not_the_screen_cell_count() {
339        // A 12x8 screen viewport at 3x2 zoom is 4x4 tiles; a 20x20 tile world leaves it fully
340        // interior, so every one of the 16 tiles is visible.
341        let c = TileCamera::new(Rect::new(0, 0, 12, 8), Size::new(20, 20), Size::new(3, 2));
342        assert_eq!(c.cells().count(), 16);
343    }
344}