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}