1use crate::bounds::WorldBounds;
28use crate::coord::{GeoCoord, WorldCoord};
29use crate::mercator::WebMercator;
30use glam::{DMat4, DVec3, DVec4};
31use std::f64::consts::PI;
32use std::fmt;
33
34pub const MAX_ZOOM: u8 = 22;
40
41#[derive(Debug, Clone, Copy, PartialEq)]
51pub struct FlatTileSelectionConfig {
52 pub footprint_pitch_threshold_rad: f64,
54 pub footprint_min_tiles: usize,
56 pub footprint_edge_steps: usize,
59 pub max_ground_distance: f64,
61 pub max_sky_distance: f64,
63}
64
65impl Default for FlatTileSelectionConfig {
66 fn default() -> Self {
67 Self {
68 footprint_pitch_threshold_rad: 0.3,
69 footprint_min_tiles: 64,
70 footprint_edge_steps: 24,
71 max_ground_distance: 500_000.0,
72 max_sky_distance: 400_000.0,
73 }
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq)]
87pub struct FlatTileView {
88 pub target_world: WorldCoord,
90 pub distance: f64,
92 pub pitch: f64,
94 pub yaw: f64,
96 pub fov_y: f64,
98 pub viewport_width: u32,
100 pub viewport_height: u32,
102}
103
104impl FlatTileView {
105 #[inline]
107 pub fn new(
108 target_world: WorldCoord,
109 distance: f64,
110 pitch: f64,
111 yaw: f64,
112 fov_y: f64,
113 viewport_width: u32,
114 viewport_height: u32,
115 ) -> Self {
116 Self {
117 target_world,
118 distance,
119 pitch,
120 yaw,
121 fov_y,
122 viewport_width,
123 viewport_height,
124 }
125 }
126}
127
128pub fn visible_tiles_flat_view(
143 bounds: &WorldBounds,
144 zoom: u8,
145 view: &FlatTileView,
146) -> Vec<TileId> {
147 visible_tiles_flat_view_with_config(bounds, zoom, view, &FlatTileSelectionConfig::default())
148}
149
150pub fn visible_tiles_flat_view_with_config(
155 bounds: &WorldBounds,
156 zoom: u8,
157 view: &FlatTileView,
158 config: &FlatTileSelectionConfig,
159) -> Vec<TileId> {
160 let mut tiles = visible_tiles(bounds, zoom);
161
162 if view.pitch <= config.footprint_pitch_threshold_rad
163 || tiles.len() <= config.footprint_min_tiles
164 {
165 return tiles;
166 }
167
168 let footprint = sampled_ground_footprint(view, config);
169 if footprint.len() < 3 {
170 return tiles;
171 }
172
173 tiles.retain(|tile| tile_intersects_ground_footprint(*tile, &footprint));
174 tiles
175}
176
177fn refine_nearby_flat_tiles(
178 tiles: Vec<TileId>,
179 zoom: u8,
180 view: &FlatTileView,
181 _footprint: &[(f64, f64)],
182) -> Vec<TileId> {
183 if zoom >= MAX_ZOOM || tiles.is_empty() {
184 return tiles;
185 }
186
187 let cam_x = view.target_world.position.x;
188 let cam_y = view.target_world.position.y;
189 let refine_radius = (view.distance * 2.5).max(60_000.0);
190 let refine_radius_sq = refine_radius * refine_radius;
191
192 let mut refined = Vec::with_capacity(tiles.len() * 3);
193 for tile in tiles {
194 let bounds = tile_bounds_world(&tile);
195 let cx = (bounds.min.position.x + bounds.max.position.x) * 0.5;
196 let cy = (bounds.min.position.y + bounds.max.position.y) * 0.5;
197 let dx = cx - cam_x;
198 let dy = cy - cam_y;
199 let dist_sq = dx * dx + dy * dy;
200
201 if dist_sq <= refine_radius_sq {
202 refined.extend_from_slice(&tile.children());
203 } else {
204 refined.push(tile);
205 }
206 }
207
208 refined.sort();
209 refined.dedup();
210 refined
211}
212
213fn sampled_ground_footprint(
214 view: &FlatTileView,
215 config: &FlatTileSelectionConfig,
216) -> Vec<(f64, f64)> {
217 let w = view.viewport_width as f64;
218 let h = view.viewport_height as f64;
219 if w <= 0.0 || h <= 0.0 {
220 return Vec::new();
221 }
222
223 let edge_steps = config.footprint_edge_steps.max(1);
224
225 let half_fov = view.fov_y / 2.0;
226 let max_angle = (view.pitch + half_fov).min(std::f64::consts::FRAC_PI_2 - 0.01);
227 let ground_far = view.distance * max_angle.tan().abs().max(1.0);
228
229 let altitude_ground_cap = view.distance * 6.0;
235 let max_ground_dist = (ground_far * 2.0)
236 .min(config.max_ground_distance.max(altitude_ground_cap));
237 let altitude_sky_cap = view.distance * 4.0;
238 let max_sky_dist = (view.distance * view.pitch.tan().abs().max(1.0) * 4.0)
239 .min(config.max_sky_distance.max(altitude_sky_cap));
240
241 let mut samples = Vec::with_capacity((edge_steps + 1) * 4);
242 let mut push_hit = |px: f64, py: f64| {
243 let (origin, dir) = screen_to_ray(view, px, py);
244
245 if dir.z.abs() < 1e-12 {
246 let xy_len = (dir.x * dir.x + dir.y * dir.y).sqrt();
247 if xy_len > 1e-12 {
248 samples.push((
249 origin.x + (dir.x / xy_len) * max_sky_dist,
250 origin.y + (dir.y / xy_len) * max_sky_dist,
251 ));
252 }
253 return;
254 }
255
256 let t = -origin.z / dir.z;
257 if t < 0.0 {
258 let xy_len = (dir.x * dir.x + dir.y * dir.y).sqrt();
259 if xy_len > 1e-12 {
260 samples.push((
261 origin.x + (dir.x / xy_len) * max_sky_dist,
262 origin.y + (dir.y / xy_len) * max_sky_dist,
263 ));
264 }
265 return;
266 }
267
268 let t_clamped = t.min(max_ground_dist);
269 samples.push((origin.x + dir.x * t_clamped, origin.y + dir.y * t_clamped));
270 };
271
272 for i in 0..=edge_steps {
273 let t = i as f64 / edge_steps as f64;
274 push_hit(t * w, 0.0);
275 }
276 for i in 1..=edge_steps {
277 let t = i as f64 / edge_steps as f64;
278 push_hit(w, t * h);
279 }
280 for i in (0..edge_steps).rev() {
281 let t = i as f64 / edge_steps as f64;
282 push_hit(t * w, h);
283 }
284 for i in (1..edge_steps).rev() {
285 let t = i as f64 / edge_steps as f64;
286 push_hit(0.0, t * h);
287 }
288
289 dedupe_nearby_points(samples, 1.0)
290}
291
292fn screen_to_ray(view: &FlatTileView, px: f64, py: f64) -> (DVec3, DVec3) {
293 let w = view.viewport_width.max(1) as f64;
294 let h = view.viewport_height.max(1) as f64;
295
296 let target_world = DVec3::new(
297 view.target_world.position.x,
298 view.target_world.position.y,
299 view.target_world.position.z,
300 );
301 let view_m = view_matrix(view, target_world);
302 let proj_m = perspective_matrix(view);
303 let vp_inv = (proj_m * view_m).inverse();
304
305 let ndc_x = (2.0 * px / w) - 1.0;
306 let ndc_y = 1.0 - (2.0 * py / h);
307
308 let near_ndc = DVec4::new(ndc_x, ndc_y, -1.0, 1.0);
309 let far_ndc = DVec4::new(ndc_x, ndc_y, 1.0, 1.0);
310
311 let near_world = vp_inv * near_ndc;
312 let far_world = vp_inv * far_ndc;
313
314 if near_world.w.abs() < 1e-12 || far_world.w.abs() < 1e-12 {
315 return (DVec3::ZERO, -DVec3::Z);
316 }
317
318 let near = DVec3::new(
319 near_world.x / near_world.w,
320 near_world.y / near_world.w,
321 near_world.z / near_world.w,
322 );
323 let far = DVec3::new(
324 far_world.x / far_world.w,
325 far_world.y / far_world.w,
326 far_world.z / far_world.w,
327 );
328
329 let dir = (far - near).normalize();
330 if dir.is_nan() {
331 return (DVec3::ZERO, -DVec3::Z);
332 }
333 (near, dir)
334}
335
336fn eye_offset(view: &FlatTileView) -> DVec3 {
337 let (sp, cp) = view.pitch.sin_cos();
338 let (sy, cy) = view.yaw.sin_cos();
339 DVec3::new(
340 -view.distance * sp * sy,
341 -view.distance * sp * cy,
342 view.distance * cp,
343 )
344}
345
346fn view_matrix(view: &FlatTileView, target_world: DVec3) -> DMat4 {
347 let eye = target_world + eye_offset(view);
348 const BLEND_RAD: f64 = 0.15;
349
350 let (sy, cy) = view.yaw.sin_cos();
351 let yaw_up = DVec3::new(sy, cy, 0.0);
352 let right = DVec3::new(cy, -sy, 0.0);
353 let look = (target_world - eye).normalize_or_zero();
354 let pitched_up = right.cross(look).normalize_or_zero();
355
356 let t = (view.pitch / BLEND_RAD).clamp(0.0, 1.0);
357 let up = (pitched_up * t + yaw_up * (1.0 - t)).normalize_or_zero();
358 let up = if up.length_squared() < 0.5 { DVec3::Z } else { up };
359
360 DMat4::look_at_rh(eye, target_world, up)
361}
362
363fn perspective_matrix(view: &FlatTileView) -> DMat4 {
364 let aspect = view.viewport_width as f64 / view.viewport_height.max(1) as f64;
365 let near = (view.distance * 0.001).max(0.01);
366 let pitch_far_scale = if view.pitch > 0.01 {
367 (1.0 / view.pitch.cos().abs().max(0.05)).min(100.0)
368 } else {
369 1.0
370 };
371 let far = view.distance * 10.0 * pitch_far_scale;
372 DMat4::perspective_rh(view.fov_y, aspect, near, far)
373}
374
375fn dedupe_nearby_points(points: Vec<(f64, f64)>, epsilon: f64) -> Vec<(f64, f64)> {
376 let mut out = Vec::with_capacity(points.len());
377 let eps2 = epsilon * epsilon;
378 for p in points {
379 let keep = out
380 .last()
381 .map(|q: &(f64, f64)| {
382 let dx = p.0 - q.0;
383 let dy = p.1 - q.1;
384 dx * dx + dy * dy > eps2
385 })
386 .unwrap_or(true);
387 if keep {
388 out.push(p);
389 }
390 }
391 if out.len() >= 2 {
392 let first = out[0];
393 if let Some(&last) = out.last() {
394 let dx = first.0 - last.0;
395 let dy = first.1 - last.1;
396 if dx * dx + dy * dy <= eps2 {
397 out.pop();
398 }
399 }
400 }
401 out
402}
403
404fn tile_intersects_ground_footprint(tile: TileId, footprint: &[(f64, f64)]) -> bool {
405 let bounds = tile_bounds_world(&tile);
406 let min_x = bounds.min.position.x;
407 let min_y = bounds.min.position.y;
408 let max_x = bounds.max.position.x;
409 let max_y = bounds.max.position.y;
410
411 if footprint
412 .iter()
413 .any(|&(x, y)| x >= min_x && x <= max_x && y >= min_y && y <= max_y)
414 {
415 return true;
416 }
417
418 let corners = [(min_x, min_y), (max_x, min_y), (max_x, max_y), (min_x, max_y)];
419 if corners.iter().any(|&p| point_in_polygon(p, footprint)) {
420 return true;
421 }
422
423 for i in 0..footprint.len() {
424 let a = footprint[i];
425 let b = footprint[(i + 1) % footprint.len()];
426 if segment_intersects_aabb(a, b, min_x, min_y, max_x, max_y) {
427 return true;
428 }
429 }
430
431 false
432}
433
434fn point_in_polygon(point: (f64, f64), polygon: &[(f64, f64)]) -> bool {
435 let (px, py) = point;
436 let mut inside = false;
437 let mut j = polygon.len() - 1;
438 for i in 0..polygon.len() {
439 let (xi, yi) = polygon[i];
440 let (xj, yj) = polygon[j];
441 if (yi > py) != (yj > py) {
442 let denom = yj - yi;
443 if denom.abs() > 1e-12 {
444 let x_at_py = (xj - xi) * (py - yi) / denom + xi;
445 if px < x_at_py {
446 inside = !inside;
447 }
448 }
449 }
450 j = i;
451 }
452 inside
453}
454
455fn segment_intersects_aabb(
456 a: (f64, f64),
457 b: (f64, f64),
458 min_x: f64,
459 min_y: f64,
460 max_x: f64,
461 max_y: f64,
462) -> bool {
463 if (a.0 >= min_x && a.0 <= max_x && a.1 >= min_y && a.1 <= max_y)
464 || (b.0 >= min_x && b.0 <= max_x && b.1 >= min_y && b.1 <= max_y)
465 {
466 return true;
467 }
468
469 let rect = [
470 ((min_x, min_y), (max_x, min_y)),
471 ((max_x, min_y), (max_x, max_y)),
472 ((max_x, max_y), (min_x, max_y)),
473 ((min_x, max_y), (min_x, min_y)),
474 ];
475
476 rect.iter().any(|&(r0, r1)| segments_intersect(a, b, r0, r1))
477}
478
479fn segments_intersect(a1: (f64, f64), a2: (f64, f64), b1: (f64, f64), b2: (f64, f64)) -> bool {
480 fn orient(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> f64 {
481 (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0)
482 }
483 fn on_segment(a: (f64, f64), b: (f64, f64), p: (f64, f64)) -> bool {
484 p.0 >= a.0.min(b.0) - 1e-9
485 && p.0 <= a.0.max(b.0) + 1e-9
486 && p.1 >= a.1.min(b.1) - 1e-9
487 && p.1 <= a.1.max(b.1) + 1e-9
488 }
489
490 let o1 = orient(a1, a2, b1);
491 let o2 = orient(a1, a2, b2);
492 let o3 = orient(b1, b2, a1);
493 let o4 = orient(b1, b2, a2);
494
495 if (o1 > 0.0 && o2 < 0.0 || o1 < 0.0 && o2 > 0.0)
496 && (o3 > 0.0 && o4 < 0.0 || o3 < 0.0 && o4 > 0.0)
497 {
498 return true;
499 }
500
501 (o1.abs() <= 1e-9 && on_segment(a1, a2, b1))
502 || (o2.abs() <= 1e-9 && on_segment(a1, a2, b2))
503 || (o3.abs() <= 1e-9 && on_segment(b1, b2, a1))
504 || (o4.abs() <= 1e-9 && on_segment(b1, b2, a2))
505}
506
507#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
519#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
520pub struct TileId {
521 pub zoom: u8,
523 pub x: u32,
525 pub y: u32,
527}
528
529impl TileId {
530 #[inline]
539 pub fn new(zoom: u8, x: u32, y: u32) -> Self {
540 debug_assert!(zoom <= MAX_ZOOM, "zoom {zoom} exceeds maximum {MAX_ZOOM}");
541 debug_assert!(
542 x < Self::axis_tiles(zoom),
543 "x={x} out of range for zoom {zoom}"
544 );
545 debug_assert!(
546 y < Self::axis_tiles(zoom),
547 "y={y} out of range for zoom {zoom}"
548 );
549 Self { zoom, x, y }
550 }
551
552 #[inline]
556 pub fn new_checked(zoom: u8, x: u32, y: u32) -> Option<Self> {
557 if zoom > MAX_ZOOM {
558 return None;
559 }
560 let n = Self::axis_tiles(zoom);
561 if x >= n || y >= n {
562 return None;
563 }
564 Some(Self { zoom, x, y })
565 }
566
567 #[inline]
575 pub fn axis_tiles(zoom: u8) -> u32 {
576 1u32 << zoom
577 }
578
579 #[inline]
585 pub fn parent(&self) -> Option<TileId> {
586 if self.zoom == 0 {
587 None
588 } else {
589 Some(TileId {
590 zoom: self.zoom - 1,
591 x: self.x / 2,
592 y: self.y / 2,
593 })
594 }
595 }
596
597 #[inline]
608 pub fn children(&self) -> [TileId; 4] {
609 let z = self.zoom + 1;
610 let x = self.x * 2;
611 let y = self.y * 2;
612 [
613 TileId { zoom: z, x, y },
614 TileId {
615 zoom: z,
616 x: x + 1,
617 y,
618 },
619 TileId {
620 zoom: z,
621 x,
622 y: y + 1,
623 },
624 TileId {
625 zoom: z,
626 x: x + 1,
627 y: y + 1,
628 },
629 ]
630 }
631
632 pub fn quadkey(&self) -> String {
639 let mut key = String::with_capacity(self.zoom as usize);
640 for i in (1..=self.zoom).rev() {
641 let mut digit: u8 = b'0';
642 let mask = 1u32 << (i - 1);
643 if (self.x & mask) != 0 {
644 digit += 1;
645 }
646 if (self.y & mask) != 0 {
647 digit += 2;
648 }
649 key.push(digit as char);
650 }
651 key
652 }
653
654 pub fn from_quadkey(key: &str) -> Option<Self> {
663 let zoom = key.len() as u8;
664 if zoom > MAX_ZOOM {
665 return None;
666 }
667
668 let mut x = 0u32;
669 let mut y = 0u32;
670
671 for (i, ch) in key.bytes().enumerate() {
672 let bit = 1u32 << (zoom as usize - 1 - i);
673 match ch {
674 b'0' => {}
675 b'1' => x |= bit,
676 b'2' => y |= bit,
677 b'3' => {
678 x |= bit;
679 y |= bit;
680 }
681 _ => return None,
682 }
683 }
684
685 Self::new_checked(zoom, x, y)
686 }
687
688 #[inline]
703 pub fn neighbor(&self, dx: i32, dy: i32) -> Option<TileId> {
704 let n = Self::axis_tiles(self.zoom) as i64;
705 let nx = ((self.x as i64 + dx as i64) % n + n) % n;
707 let ny = self.y as i64 + dy as i64;
709 if ny < 0 || ny >= n {
710 return None;
711 }
712 Some(TileId {
713 zoom: self.zoom,
714 x: nx as u32,
715 y: ny as u32,
716 })
717 }
718}
719
720impl fmt::Display for TileId {
721 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
723 write!(f, "{}/{}/{}", self.zoom, self.x, self.y)
724 }
725}
726
727#[derive(Debug, Clone, Copy, PartialEq)]
739#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
740pub struct TileCoord {
741 pub zoom: u8,
743 pub x: f64,
745 pub y: f64,
747}
748
749impl TileCoord {
750 #[inline]
752 pub fn new(zoom: u8, x: f64, y: f64) -> Self {
753 Self { zoom, x, y }
754 }
755
756 #[inline]
762 pub fn tile_id(&self) -> TileId {
763 let n = TileId::axis_tiles(self.zoom);
764 let x = (self.x.max(0.0) as u32).min(n.saturating_sub(1));
768 let y = (self.y.max(0.0) as u32).min(n.saturating_sub(1));
769 TileId {
770 zoom: self.zoom,
771 x,
772 y,
773 }
774 }
775}
776
777impl fmt::Display for TileCoord {
778 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
779 write!(f, "{}/{:.3}/{:.3}", self.zoom, self.x, self.y)
780 }
781}
782
783pub fn geo_to_tile(geo: &GeoCoord, zoom: u8) -> TileCoord {
803 let n = TileId::axis_tiles(zoom) as f64;
804 let lat_rad = geo.lat.to_radians();
805 let x = (geo.lon + 180.0) / 360.0 * n;
806 let y = (1.0 - (lat_rad.tan() + 1.0 / lat_rad.cos()).ln() / PI) / 2.0 * n;
807 TileCoord::new(zoom, x, y)
808}
809
810pub fn geo_to_tile_checked(geo: &GeoCoord, zoom: u8) -> Option<TileCoord> {
815 if zoom > MAX_ZOOM || !geo.is_web_mercator_valid() {
816 return None;
817 }
818 Some(geo_to_tile(geo, zoom))
819}
820
821pub fn tile_to_geo(tile: &TileId) -> GeoCoord {
827 tile_xy_to_geo(tile.zoom, tile.x as f64, tile.y as f64)
828}
829
830pub fn tile_xy_to_geo(zoom: u8, x: f64, y: f64) -> GeoCoord {
837 let n = TileId::axis_tiles(zoom) as f64;
838 let lon = x / n * 360.0 - 180.0;
839 let lat_rad = (PI * (1.0 - 2.0 * y / n)).sinh().atan();
840 GeoCoord::from_lat_lon(lat_rad.to_degrees(), lon)
841}
842
843pub fn tile_bounds_world(tile: &TileId) -> WorldBounds {
849 let nw = tile_to_geo(tile);
851 let se = tile_xy_to_geo(tile.zoom, tile.x as f64 + 1.0, tile.y as f64 + 1.0);
852 let min_world = WebMercator::project(&GeoCoord::from_lat_lon(se.lat, nw.lon));
853 let max_world = WebMercator::project(&GeoCoord::from_lat_lon(nw.lat, se.lon));
854 WorldBounds::new(min_world, max_world)
855}
856
857pub fn visible_tiles(bounds: &WorldBounds, zoom: u8) -> Vec<TileId> {
879 let extent = WebMercator::max_extent();
880 let world_size = WebMercator::world_size();
881 let n = TileId::axis_tiles(zoom);
882
883 let spans_full_world_x = (bounds.max.position.x - bounds.min.position.x) >= world_size - 1.0;
886
887 let x_min_raw: i64;
888 let x_count: u32;
889 if spans_full_world_x {
890 x_min_raw = 0;
891 x_count = n;
892 } else {
893 let tile_world_width = world_size / n as f64;
894 x_min_raw = ((bounds.min.position.x + extent) / tile_world_width).floor() as i64;
895 let x_max_raw = ((bounds.max.position.x + extent) / tile_world_width).floor() as i64;
896 x_count = (x_max_raw - x_min_raw + 1).clamp(0, n as i64) as u32;
897 }
898
899 let world_y_to_tile_y = |world_y: f64| {
900 (((extent - world_y.clamp(-extent, extent)) / world_size) * n as f64)
901 .floor()
902 .clamp(0.0, n.saturating_sub(1) as f64) as u32
903 };
904
905 let y_min = world_y_to_tile_y(bounds.max.position.y);
906 let y_max = world_y_to_tile_y(bounds.min.position.y);
907
908 let mut tiles = Vec::with_capacity((x_count * (y_max - y_min + 1)) as usize);
909 for y in y_min..=y_max {
910 for i in 0..x_count {
911 let x = if spans_full_world_x {
912 i
913 } else {
914 (x_min_raw + i as i64).rem_euclid(n as i64) as u32
915 };
916 tiles.push(TileId { zoom, x, y });
917 }
918 }
919 tiles
920}
921
922pub fn visible_tiles_checked(bounds: &WorldBounds, zoom: u8) -> Option<Vec<TileId>> {
924 if zoom > MAX_ZOOM {
925 return None;
926 }
927 Some(visible_tiles(bounds, zoom))
928}
929
930pub fn visible_tiles_lod(
932 bounds: &WorldBounds,
933 base_zoom: u8,
934 camera_world: (f64, f64),
935 near_threshold: f64,
936 mid_threshold: f64,
937 max_tiles: usize,
938) -> Vec<TileId> {
939 use std::collections::HashSet;
940
941 let near_zoom = (base_zoom + 1).min(MAX_ZOOM);
942 let far_zoom = base_zoom.saturating_sub(1);
943
944 let near_tiles = visible_tiles(bounds, near_zoom);
945 let mid_tiles = visible_tiles(bounds, base_zoom);
946 let far_tiles = visible_tiles(bounds, far_zoom);
947
948 let near_sq = near_threshold * near_threshold;
949 let mid_sq = mid_threshold * mid_threshold;
950
951 fn tile_dist_sq(tile: &TileId, cam: (f64, f64)) -> f64 {
952 let b = tile_bounds_world(tile);
953 let cx = (b.min.position.x + b.max.position.x) * 0.5;
954 let cy = (b.min.position.y + b.max.position.y) * 0.5;
955 let dx = cx - cam.0;
956 let dy = cy - cam.1;
957 dx * dx + dy * dy
958 }
959
960 let mut result: Vec<(TileId, f64)> = Vec::new();
961 let mut seen = HashSet::new();
962
963 for tile in &near_tiles {
964 let d2 = tile_dist_sq(tile, camera_world);
965 if d2 <= near_sq && seen.insert(*tile) {
966 result.push((*tile, d2));
967 }
968 }
969
970 let near_parent_set: HashSet<TileId> = if near_zoom > base_zoom {
971 result.iter().filter_map(|(t, _)| t.parent()).collect()
972 } else {
973 HashSet::new()
974 };
975
976 for tile in &mid_tiles {
977 let d2 = tile_dist_sq(tile, camera_world);
978 if d2 <= mid_sq && seen.insert(*tile) {
979 if near_parent_set.contains(tile) {
980 let children = tile.children();
981 if children.iter().all(|c| seen.contains(c)) {
982 continue;
983 }
984 }
985 result.push((*tile, d2));
986 }
987 }
988
989 let mid_parent_set: HashSet<TileId> = if base_zoom > far_zoom {
990 result
991 .iter()
992 .filter(|(t, _)| t.zoom == base_zoom)
993 .filter_map(|(t, _)| t.parent())
994 .collect()
995 } else {
996 HashSet::new()
997 };
998
999 for tile in &far_tiles {
1000 let d2 = tile_dist_sq(tile, camera_world);
1001 if d2 > mid_sq && seen.insert(*tile) {
1002 if mid_parent_set.contains(tile) {
1003 let children = tile.children();
1004 if children.iter().all(|c| seen.contains(c)) {
1005 continue;
1006 }
1007 }
1008 result.push((*tile, d2));
1009 }
1010 }
1011
1012 result.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1013 result.truncate(max_tiles);
1014
1015 result.into_iter().map(|(t, _)| t).collect()
1016}
1017
1018pub fn visible_tiles_frustum(
1020 frustum: &crate::frustum::Frustum,
1021 target_zoom: u8,
1022 max_tiles: usize,
1023 camera_world: (f64, f64),
1024) -> Vec<TileId> {
1025 let target_zoom = target_zoom.min(MAX_ZOOM);
1026
1027 struct StackEntry {
1028 tile: TileId,
1029 fully_visible: bool,
1030 }
1031
1032 let mut stack = Vec::with_capacity(64);
1033 stack.push(StackEntry {
1034 tile: TileId::new(0, 0, 0),
1035 fully_visible: false,
1036 });
1037
1038 let mut result: Vec<(TileId, f64)> = Vec::new();
1039
1040 while let Some(entry) = stack.pop() {
1041 let tile = entry.tile;
1042 let bounds = tile_bounds_world(&tile);
1043
1044 if !entry.fully_visible {
1045 let mut all_inside = true;
1046 let mut any_outside = false;
1047 let min = bounds.min.position;
1048 let max = bounds.max.position;
1049
1050 for plane in frustum.planes() {
1051 let px = if plane.normal()[0] >= 0.0 { max.x } else { min.x };
1052 let py = if plane.normal()[1] >= 0.0 { max.y } else { min.y };
1053 let pz = if plane.normal()[2] >= 0.0 { max.z } else { min.z };
1054
1055 if plane.distance_to_point(px, py, pz) < 0.0 {
1056 any_outside = true;
1057 break;
1058 }
1059
1060 let nx = if plane.normal()[0] >= 0.0 { min.x } else { max.x };
1061 let ny = if plane.normal()[1] >= 0.0 { min.y } else { max.y };
1062 let nz = if plane.normal()[2] >= 0.0 { min.z } else { max.z };
1063
1064 if plane.distance_to_point(nx, ny, nz) < 0.0 {
1065 all_inside = false;
1066 }
1067 }
1068
1069 if any_outside {
1070 continue;
1071 }
1072
1073 if tile.zoom >= target_zoom {
1074 let cx = (bounds.min.position.x + bounds.max.position.x) * 0.5;
1075 let cy = (bounds.min.position.y + bounds.max.position.y) * 0.5;
1076 let dx = cx - camera_world.0;
1077 let dy = cy - camera_world.1;
1078 result.push((tile, dx * dx + dy * dy));
1079 continue;
1080 }
1081
1082 for child in tile.children().iter().rev() {
1083 stack.push(StackEntry {
1084 tile: *child,
1085 fully_visible: all_inside,
1086 });
1087 }
1088 } else {
1089 if tile.zoom >= target_zoom {
1090 let cx = (bounds.min.position.x + bounds.max.position.x) * 0.5;
1091 let cy = (bounds.min.position.y + bounds.max.position.y) * 0.5;
1092 let dx = cx - camera_world.0;
1093 let dy = cy - camera_world.1;
1094 result.push((tile, dx * dx + dy * dy));
1095 continue;
1096 }
1097
1098 for child in tile.children().iter().rev() {
1099 stack.push(StackEntry {
1100 tile: *child,
1101 fully_visible: true,
1102 });
1103 }
1104 }
1105 }
1106
1107 result.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1108 result.truncate(max_tiles);
1109 result.into_iter().map(|(t, _)| t).collect()
1110}
1111
1112pub fn visible_tiles_flat_view_capped(
1115 bounds: &WorldBounds,
1116 zoom: u8,
1117 view: &FlatTileView,
1118 max_tiles: usize,
1119) -> Vec<TileId> {
1120 visible_tiles_flat_view_capped_with_config(
1121 bounds,
1122 zoom,
1123 view,
1124 &FlatTileSelectionConfig::default(),
1125 max_tiles,
1126 )
1127}
1128
1129pub fn visible_tiles_flat_view_capped_with_config(
1133 bounds: &WorldBounds,
1134 zoom: u8,
1135 view: &FlatTileView,
1136 config: &FlatTileSelectionConfig,
1137 max_tiles: usize,
1138) -> Vec<TileId> {
1139 let mut tiles = visible_tiles_flat_view_with_config(bounds, zoom, view, config);
1140 if tiles.len() <= max_tiles {
1141 return tiles;
1142 }
1143
1144 let cam_x = view.target_world.position.x;
1145 let cam_y = view.target_world.position.y;
1146 tiles.sort_by(|a, b| {
1147 let ad = tile_distance_sq(*a, cam_x, cam_y);
1148 let bd = tile_distance_sq(*b, cam_x, cam_y);
1149 ad.partial_cmp(&bd)
1150 .unwrap_or(std::cmp::Ordering::Equal)
1151 .then_with(|| a.zoom.cmp(&b.zoom))
1152 .then_with(|| a.y.cmp(&b.y))
1153 .then_with(|| a.x.cmp(&b.x))
1154 });
1155 tiles.truncate(max_tiles);
1156 tiles
1157}
1158
1159fn tile_distance_sq(tile: TileId, cam_x: f64, cam_y: f64) -> f64 {
1160 let bounds = tile_bounds_world(&tile);
1161 let cx = (bounds.min.position.x + bounds.max.position.x) * 0.5;
1162 let cy = (bounds.min.position.y + bounds.max.position.y) * 0.5;
1163 let dx = cx - cam_x;
1164 let dy = cy - cam_y;
1165 dx * dx + dy * dy
1166}
1167
1168pub fn visible_tiles_flat_view_refined_capped(
1171 bounds: &WorldBounds,
1172 zoom: u8,
1173 view: &FlatTileView,
1174 max_tiles: usize,
1175) -> Vec<TileId> {
1176 visible_tiles_flat_view_refined_capped_with_config(
1177 bounds,
1178 zoom,
1179 view,
1180 &FlatTileSelectionConfig::default(),
1181 max_tiles,
1182 )
1183}
1184
1185pub fn visible_tiles_flat_view_refined_capped_with_config(
1187 bounds: &WorldBounds,
1188 zoom: u8,
1189 view: &FlatTileView,
1190 config: &FlatTileSelectionConfig,
1191 max_tiles: usize,
1192) -> Vec<TileId> {
1193 let footprint_filtered = visible_tiles_flat_view_with_config(bounds, zoom, view, config);
1194 if footprint_filtered.is_empty() {
1195 return footprint_filtered;
1196 }
1197
1198 let mut refined = footprint_filtered;
1199 if refined.len() < max_tiles
1200 && zoom < MAX_ZOOM
1201 && view.pitch > config.footprint_pitch_threshold_rad
1202 {
1203 let footprint = sampled_ground_footprint(view, config);
1204 if footprint.len() >= 3 {
1205 refined = refine_nearby_flat_tiles(refined, zoom, view, &footprint);
1206 }
1207 }
1208
1209 if refined.len() <= max_tiles {
1210 return refined;
1211 }
1212
1213 let cam_x = view.target_world.position.x;
1214 let cam_y = view.target_world.position.y;
1215 refined.sort_by(|a, b| {
1216 let ad = tile_distance_sq(*a, cam_x, cam_y);
1217 let bd = tile_distance_sq(*b, cam_x, cam_y);
1218 ad.partial_cmp(&bd)
1219 .unwrap_or(std::cmp::Ordering::Equal)
1220 .then_with(|| a.zoom.cmp(&b.zoom))
1221 .then_with(|| a.y.cmp(&b.y))
1222 .then_with(|| a.x.cmp(&b.x))
1223 });
1224 refined.truncate(max_tiles);
1225 refined
1226}
1227
1228#[derive(Debug, Clone)]
1239pub struct CoveringTilesOptions {
1240 pub min_zoom: u8,
1243 pub max_zoom: u8,
1246 pub round_zoom: bool,
1248 pub tile_size: u32,
1250 pub max_tiles: usize,
1252 pub allow_variable_zoom: bool,
1255 pub render_world_copies: bool,
1257}
1258
1259impl Default for CoveringTilesOptions {
1260 fn default() -> Self {
1261 Self {
1262 min_zoom: 0,
1263 max_zoom: MAX_ZOOM,
1264 round_zoom: false,
1265 tile_size: 256,
1266 max_tiles: 512,
1267 allow_variable_zoom: true,
1268 render_world_copies: true,
1269 }
1270 }
1271}
1272
1273#[derive(Debug, Clone, Copy)]
1280pub struct CoveringCamera {
1281 pub camera_x: f64,
1283 pub camera_y: f64,
1285 pub camera_to_center_z: f64,
1288 pub center_x: f64,
1290 pub center_y: f64,
1292 pub pitch_rad: f64,
1294 pub fov_deg: f64,
1296 pub zoom: f64,
1299 pub display_tile_size: u32,
1301}
1302
1303fn default_calculate_tile_zoom(
1316 requested_center_zoom: f64,
1317 dist_2d: f64,
1318 dist_z: f64,
1319 dist_center_3d: f64,
1320 fov_deg: f64,
1321) -> f64 {
1322 const MAX_ZOOM_LEVELS_ON_SCREEN: f64 = 9.314;
1323 const TILE_COUNT_MAX_MIN_RATIO: f64 = 3.0;
1324 const MAX_MERCATOR_HORIZON_ANGLE: f64 = 85.051129; fn scale_zoom(s: f64) -> f64 {
1327 s.log2()
1328 }
1329
1330 fn integral_cos_x_by_p(p: f64, x1: f64, x2: f64) -> f64 {
1331 let num_points = 10usize;
1332 let dx = (x2 - x1) / num_points as f64;
1333 let mut sum = 0.0;
1334 for i in 0..num_points {
1335 let x = x1 + (i as f64 + 0.5) / num_points as f64 * (x2 - x1);
1336 sum += dx * x.cos().powf(p);
1337 }
1338 sum
1339 }
1340
1341 let horizon_rad = (MAX_MERCATOR_HORIZON_ANGLE).to_radians();
1342 let fov_rad = fov_deg.to_radians();
1343
1344 let pitch_tile_loading_behavior = 2.0
1345 * ((MAX_ZOOM_LEVELS_ON_SCREEN - 1.0)
1346 / scale_zoom(
1347 (horizon_rad - fov_rad).cos()
1348 / horizon_rad.cos(),
1349 )
1350 - 1.0);
1351
1352 let center_pitch = if dist_center_3d > 1e-15 {
1353 (dist_z / dist_center_3d).clamp(-1.0, 1.0).acos()
1354 } else {
1355 0.0
1356 };
1357
1358 let half_fov_rad = fov_rad / 2.0;
1359 let _tile_count_pitch0 =
1360 2.0 * integral_cos_x_by_p(pitch_tile_loading_behavior - 1.0, 0.0, half_fov_rad);
1361 let highest_pitch = horizon_rad.min(center_pitch + half_fov_rad);
1362 let lowest_pitch = highest_pitch.min(center_pitch - half_fov_rad);
1363 let tile_count =
1364 integral_cos_x_by_p(pitch_tile_loading_behavior - 1.0, lowest_pitch, highest_pitch);
1365 let tile_count_pitch0 =
1366 2.0 * integral_cos_x_by_p(pitch_tile_loading_behavior - 1.0, 0.0, half_fov_rad);
1367
1368 let this_tile_pitch = if dist_z.abs() > 1e-15 {
1369 (dist_2d / dist_z).atan()
1370 } else {
1371 std::f64::consts::FRAC_PI_2
1372 };
1373 let dist_tile_3d = (dist_2d * dist_2d + dist_z * dist_z).sqrt();
1374
1375 let mut desired_z = requested_center_zoom;
1376 if dist_tile_3d > 1e-15 {
1377 desired_z += scale_zoom(
1378 dist_center_3d
1379 / dist_tile_3d
1380 / half_fov_rad.cos().max(0.5),
1381 );
1382 }
1383 desired_z += pitch_tile_loading_behavior * scale_zoom(this_tile_pitch.cos()) / 2.0;
1384 desired_z -=
1385 scale_zoom((tile_count / tile_count_pitch0 / TILE_COUNT_MAX_MIN_RATIO).max(1.0)) / 2.0;
1386
1387 desired_z
1388}
1389
1390fn covering_zoom_level(cam: &CoveringCamera, opts: &CoveringTilesOptions, round: bool) -> f64 {
1394 fn scale_zoom(s: f64) -> f64 {
1395 s.log2()
1396 }
1397 let raw = cam.zoom + scale_zoom(cam.display_tile_size as f64 / opts.tile_size as f64);
1398 let z = if round { raw.round() } else { raw.floor() };
1399 z.max(0.0)
1400}
1401
1402fn dist_to_tile_2d(cx: f64, cy: f64, tx: u32, ty: u32, z: u8) -> f64 {
1404 let n = (1u64 << z as u64) as f64;
1405 let inv = 1.0 / n;
1406 let tile_min_x = tx as f64 * inv;
1407 let tile_min_y = ty as f64 * inv;
1408 let tile_max_x = (tx + 1) as f64 * inv;
1409 let tile_max_y = (ty + 1) as f64 * inv;
1410
1411 let dx = if cx < tile_min_x {
1412 tile_min_x - cx
1413 } else if cx > tile_max_x {
1414 cx - tile_max_x
1415 } else {
1416 0.0
1417 };
1418 let dy = if cy < tile_min_y {
1419 tile_min_y - cy
1420 } else if cy > tile_max_y {
1421 cy - tile_max_y
1422 } else {
1423 0.0
1424 };
1425 (dx * dx + dy * dy).sqrt()
1426}
1427
1428pub fn visible_tiles_covering(
1445 frustum: &crate::frustum::Frustum,
1446 cam: &CoveringCamera,
1447 opts: &CoveringTilesOptions,
1448) -> Vec<TileId> {
1449 let desired_z = covering_zoom_level(cam, opts, opts.round_zoom);
1450 let nominal_z = (desired_z as u8).min(opts.max_zoom).max(0);
1451 let num_tiles_f = (1u64 << nominal_z as u64) as f64;
1452
1453 let center_tx = num_tiles_f * cam.center_x;
1454 let center_ty = num_tiles_f * cam.center_y;
1455
1456 let dist_center_2d =
1457 ((cam.center_x - cam.camera_x).powi(2) + (cam.center_y - cam.camera_y).powi(2)).sqrt();
1458 let dist_z = cam.camera_to_center_z;
1459 let dist_center_3d = (dist_center_2d * dist_center_2d + dist_z * dist_z).sqrt();
1460
1461 let requested_center_zoom = cam.zoom
1462 + if cam.display_tile_size > 0 && opts.tile_size > 0 {
1463 (cam.display_tile_size as f64 / opts.tile_size as f64).log2()
1464 } else {
1465 0.0
1466 };
1467
1468 struct StackEntry {
1469 zoom: u8,
1470 x: u32,
1471 y: u32,
1472 wrap: i32,
1473 fully_visible: bool,
1474 }
1475
1476 let mut stack: Vec<StackEntry> = Vec::with_capacity(64);
1477
1478 if opts.render_world_copies {
1480 for i in 1..=3i32 {
1481 stack.push(StackEntry {
1482 zoom: 0,
1483 x: 0,
1484 y: 0,
1485 wrap: -i,
1486 fully_visible: false,
1487 });
1488 stack.push(StackEntry {
1489 zoom: 0,
1490 x: 0,
1491 y: 0,
1492 wrap: i,
1493 fully_visible: false,
1494 });
1495 }
1496 }
1497 stack.push(StackEntry {
1498 zoom: 0,
1499 x: 0,
1500 y: 0,
1501 wrap: 0,
1502 fully_visible: false,
1503 });
1504
1505 struct ResultEntry {
1506 tile: TileId,
1507 wrap: i32,
1508 dist_sq: f64,
1509 }
1510
1511 let mut result: Vec<ResultEntry> = Vec::new();
1512 let world_size = WebMercator::world_size();
1513
1514 while let Some(entry) = stack.pop() {
1515 let z = entry.zoom;
1516 let x = entry.x;
1517 let y = entry.y;
1518 let mut fully_visible = entry.fully_visible;
1519
1520 let tile_for_bounds = TileId {
1522 zoom: z,
1523 x: x % TileId::axis_tiles(z).max(1),
1524 y: y.min(TileId::axis_tiles(z).saturating_sub(1)),
1525 };
1526 let base_bounds = tile_bounds_world(&tile_for_bounds);
1527 let wrap_offset = entry.wrap as f64 * world_size;
1528 let bounds = WorldBounds::new(
1529 WorldCoord::new(
1530 base_bounds.min.position.x + wrap_offset,
1531 base_bounds.min.position.y,
1532 0.0,
1533 ),
1534 WorldCoord::new(
1535 base_bounds.max.position.x + wrap_offset,
1536 base_bounds.max.position.y,
1537 0.0,
1538 ),
1539 );
1540
1541 if !fully_visible {
1542 if !frustum.intersects_aabb(&bounds) {
1543 continue;
1544 }
1545 let min = bounds.min.position;
1547 let max = bounds.max.position;
1548 let mut all_inside = true;
1549 for plane in frustum.planes() {
1550 let nx = if plane.normal()[0] >= 0.0 { min.x } else { max.x };
1551 let ny = if plane.normal()[1] >= 0.0 { min.y } else { max.y };
1552 let nz = if plane.normal()[2] >= 0.0 { min.z } else { max.z };
1553 if plane.distance_to_point(nx, ny, nz) < 0.0 {
1554 all_inside = false;
1555 break;
1556 }
1557 }
1558 fully_visible = all_inside;
1559 }
1560
1561 let this_tile_desired_z = if opts.allow_variable_zoom && cam.pitch_rad > 0.05 {
1563 let d2d = dist_to_tile_2d(cam.camera_x, cam.camera_y, x, y, z);
1564 let z_val = default_calculate_tile_zoom(
1565 requested_center_zoom,
1566 d2d,
1567 dist_z,
1568 dist_center_3d,
1569 cam.fov_deg,
1570 );
1571 let z_rounded = if opts.round_zoom {
1572 z_val.round()
1573 } else {
1574 z_val.floor()
1575 };
1576 (z_rounded.max(0.0) as u8).min(opts.max_zoom)
1577 } else {
1578 nominal_z
1579 };
1580
1581 if z >= this_tile_desired_z {
1583 if z < opts.min_zoom {
1584 continue;
1585 }
1586 let dz_shift = nominal_z.saturating_sub(z);
1588 let tile_center_x = (x as f64 + 0.5) * (1u64 << dz_shift) as f64;
1589 let tile_center_y = (y as f64 + 0.5) * (1u64 << dz_shift) as f64;
1590 let dx = center_tx - tile_center_x;
1591 let dy = center_ty - tile_center_y;
1592
1593 let n = TileId::axis_tiles(z);
1595 let wrapped_x = if n > 0 {
1596 ((x as i64).rem_euclid(n as i64)) as u32
1597 } else {
1598 0
1599 };
1600
1601 result.push(ResultEntry {
1602 tile: TileId {
1603 zoom: z,
1604 x: wrapped_x,
1605 y: y.min(n.saturating_sub(1)),
1606 },
1607 wrap: entry.wrap,
1608 dist_sq: dx * dx + dy * dy,
1609 });
1610 continue;
1611 }
1612
1613 let child_z = z + 1;
1615 if child_z > MAX_ZOOM {
1616 continue;
1617 }
1618 for i in (0..4u32).rev() {
1619 let cx = (x << 1) + (i % 2);
1620 let cy = (y << 1) + (i >> 1);
1621 stack.push(StackEntry {
1622 zoom: child_z,
1623 x: cx,
1624 y: cy,
1625 wrap: entry.wrap,
1626 fully_visible,
1627 });
1628 }
1629 }
1630
1631 result.sort_by(|a, b| {
1639 a.dist_sq
1640 .partial_cmp(&b.dist_sq)
1641 .unwrap_or(std::cmp::Ordering::Equal)
1642 });
1643
1644 let mut seen = std::collections::HashSet::new();
1646 let mut final_tiles = Vec::with_capacity(result.len().min(opts.max_tiles));
1647 for entry in result {
1648 if seen.insert((entry.wrap, entry.tile.zoom, entry.tile.x, entry.tile.y)) {
1649 final_tiles.push(entry.tile);
1650 if final_tiles.len() >= opts.max_tiles {
1651 break;
1652 }
1653 }
1654 }
1655
1656 final_tiles
1657}
1658
1659#[cfg(test)]
1660mod tests {
1661 use super::*;
1662 use crate::mercator::WebMercator;
1663
1664 #[test]
1665 fn visible_tiles_flat_view_preserves_coverage() {
1666 let bounds = WorldBounds::new(
1667 WebMercator::project(&GeoCoord::from_lat_lon(37.7749, -122.4194)),
1668 WebMercator::project(&GeoCoord::from_lat_lon(37.8049, -122.3894)),
1669 );
1670 let zoom = 12;
1671 let view = FlatTileView::new(
1672 bounds.center(),
1673 1000.0,
1674 std::f64::consts::FRAC_PI_4,
1675 0.0,
1676 std::f64::consts::FRAC_PI_4,
1677 800,
1678 600,
1679 );
1680
1681 let tiles = visible_tiles_flat_view(&bounds, zoom, &view);
1682
1683 assert!(!tiles.is_empty());
1685 assert!(tiles.iter().all(|t| t.zoom == zoom));
1686 }
1687
1688 #[test]
1689 fn visible_tiles_flat_view_capped_does_not_exceed_limit() {
1690 let bounds = WorldBounds::new(
1691 WebMercator::project(&GeoCoord::from_lat_lon(37.7749, -122.4194)),
1692 WebMercator::project(&GeoCoord::from_lat_lon(37.8049, -122.3894)),
1693 );
1694 let zoom = 12;
1695 let view = FlatTileView::new(
1696 bounds.center(),
1697 1000.0,
1698 std::f64::consts::FRAC_PI_4,
1699 0.0,
1700 std::f64::consts::FRAC_PI_4,
1701 800,
1702 600,
1703 );
1704
1705 let max_tiles = 10;
1706 let tiles = visible_tiles_flat_view_capped(&bounds, zoom, &view, max_tiles);
1707
1708 assert!(tiles.len() <= max_tiles);
1710 }
1711
1712 #[test]
1713 fn visible_tiles_covering_top_down_returns_uniform_zoom() {
1714 use crate::frustum::Frustum;
1715 use glam::DMat4;
1716
1717 let proj = DMat4::perspective_rh(std::f64::consts::FRAC_PI_4, 1.5, 1.0, 500_000.0);
1719 let eye = glam::DVec3::new(0.0, 0.0, 50_000.0);
1720 let target = glam::DVec3::ZERO;
1721 let view = DMat4::look_at_rh(eye, target, glam::DVec3::Y);
1722 let frustum = Frustum::from_view_projection(&(proj * view));
1723
1724 let world_size = crate::mercator::WebMercator::world_size();
1725 let cam = CoveringCamera {
1726 camera_x: 0.5,
1727 camera_y: 0.5,
1728 camera_to_center_z: 50_000.0 / world_size,
1729 center_x: 0.5,
1730 center_y: 0.5,
1731 pitch_rad: 0.0,
1732 fov_deg: 45.0,
1733 zoom: 4.0,
1734 display_tile_size: 256,
1735 };
1736 let opts = CoveringTilesOptions {
1737 min_zoom: 0,
1738 max_zoom: MAX_ZOOM,
1739 round_zoom: false,
1740 tile_size: 256,
1741 max_tiles: 512,
1742 allow_variable_zoom: true,
1743 render_world_copies: false,
1744 };
1745
1746 let tiles = visible_tiles_covering(&frustum, &cam, &opts);
1747 assert!(!tiles.is_empty());
1748 let first_zoom = tiles[0].zoom;
1749 assert!(tiles.iter().all(|t| t.zoom == first_zoom));
1751 }
1752
1753 #[test]
1754 fn visible_tiles_covering_pitched_produces_variable_zoom() {
1755 use crate::frustum::Frustum;
1756 use glam::DMat4;
1757
1758 let proj = DMat4::perspective_rh(std::f64::consts::FRAC_PI_4, 1.5, 1.0, 500_000.0);
1760 let eye = glam::DVec3::new(0.0, -40_000.0, 20_000.0);
1761 let target = glam::DVec3::ZERO;
1762 let view = DMat4::look_at_rh(eye, target, glam::DVec3::Z);
1763 let frustum = Frustum::from_view_projection(&(proj * view));
1764
1765 let world_size = crate::mercator::WebMercator::world_size();
1766 let pitch_rad = 1.1; let cam = CoveringCamera {
1768 camera_x: 0.5,
1769 camera_y: 0.5 - 40_000.0 / world_size,
1770 camera_to_center_z: 20_000.0 / world_size,
1771 center_x: 0.5,
1772 center_y: 0.5,
1773 pitch_rad,
1774 fov_deg: 45.0,
1775 zoom: 8.0,
1776 display_tile_size: 256,
1777 };
1778 let opts = CoveringTilesOptions {
1779 min_zoom: 0,
1780 max_zoom: MAX_ZOOM,
1781 round_zoom: false,
1782 tile_size: 256,
1783 max_tiles: 512,
1784 allow_variable_zoom: true,
1785 render_world_copies: false,
1786 };
1787
1788 let tiles = visible_tiles_covering(&frustum, &cam, &opts);
1789 assert!(!tiles.is_empty());
1790 let zooms: std::collections::HashSet<u8> = tiles.iter().map(|t| t.zoom).collect();
1791 assert!(
1793 zooms.len() > 1,
1794 "Expected multiple zoom levels at steep pitch, got: {:?}",
1795 zooms
1796 );
1797 }
1798
1799 #[test]
1800 fn visible_tiles_covering_respects_max_tiles() {
1801 use crate::frustum::Frustum;
1802 use glam::DMat4;
1803
1804 let vp = DMat4::orthographic_rh(-500_000.0, 500_000.0, -500_000.0, 500_000.0, -500_000.0, 500_000.0);
1805 let frustum = Frustum::from_view_projection(&vp);
1806
1807 let cam = CoveringCamera {
1808 camera_x: 0.5,
1809 camera_y: 0.5,
1810 camera_to_center_z: 0.01,
1811 center_x: 0.5,
1812 center_y: 0.5,
1813 pitch_rad: 0.0,
1814 fov_deg: 45.0,
1815 zoom: 10.0,
1816 display_tile_size: 256,
1817 };
1818 let opts = CoveringTilesOptions {
1819 min_zoom: 0,
1820 max_zoom: MAX_ZOOM,
1821 round_zoom: false,
1822 tile_size: 256,
1823 max_tiles: 5,
1824 allow_variable_zoom: false,
1825 render_world_copies: false,
1826 };
1827
1828 let tiles = visible_tiles_covering(&frustum, &cam, &opts);
1829 assert!(tiles.len() <= 5);
1830 }
1831
1832 #[test]
1833 fn visible_tiles_covering_no_duplicates() {
1834 use crate::frustum::Frustum;
1835 use glam::DMat4;
1836
1837 let proj = DMat4::perspective_rh(std::f64::consts::FRAC_PI_4, 1.5, 1.0, 500_000.0);
1838 let eye = glam::DVec3::new(0.0, 0.0, 50_000.0);
1839 let view = DMat4::look_at_rh(eye, glam::DVec3::ZERO, glam::DVec3::Y);
1840 let frustum = Frustum::from_view_projection(&(proj * view));
1841
1842 let cam = CoveringCamera {
1843 camera_x: 0.5,
1844 camera_y: 0.5,
1845 camera_to_center_z: 0.001,
1846 center_x: 0.5,
1847 center_y: 0.5,
1848 pitch_rad: 0.0,
1849 fov_deg: 45.0,
1850 zoom: 4.0,
1851 display_tile_size: 256,
1852 };
1853 let opts = CoveringTilesOptions {
1854 render_world_copies: true,
1855 ..CoveringTilesOptions::default()
1856 };
1857
1858 let tiles = visible_tiles_covering(&frustum, &cam, &opts);
1859 let unique: std::collections::HashSet<_> = tiles.iter().collect();
1860 assert_eq!(tiles.len(), unique.len(), "Covering tiles should have no duplicates");
1861 }
1862
1863 #[test]
1864 fn high_altitude_pitched_flat_view_selects_enough_tiles() {
1865 let center = WebMercator::project(&GeoCoord::from_lat_lon(40.839, -72.9));
1869 let distance = 1_406_424.0; let pitch = 48.4_f64.to_radians();
1871 let yaw = -20.3_f64.to_radians();
1872 let fov_y = 45.0_f64.to_radians();
1873 let view = FlatTileView::new(center, distance, pitch, yaw, fov_y, 1280, 720);
1874
1875 let half = distance * 4.0;
1877 let bounds = WorldBounds::new(
1878 WorldCoord::new(center.position.x - half, center.position.y - half, 0.0),
1879 WorldCoord::new(center.position.x + half, center.position.y + half, 0.0),
1880 );
1881
1882 let tiles = visible_tiles_flat_view_with_config(
1883 &bounds,
1884 7,
1885 &view,
1886 &FlatTileSelectionConfig::default(),
1887 );
1888
1889 assert!(
1893 tiles.len() >= 10,
1894 "Expected >= 10 tiles at zoom 7 high altitude, got {}",
1895 tiles.len()
1896 );
1897 }
1898
1899 #[test]
1900 fn visible_tiles_covering_variable_zoom_disabled_gives_uniform_zoom() {
1901 use crate::frustum::Frustum;
1902 use glam::DMat4;
1903
1904 let proj = DMat4::perspective_rh(std::f64::consts::FRAC_PI_4, 1.5, 1.0, 500_000.0);
1905 let eye = glam::DVec3::new(0.0, -40_000.0, 20_000.0);
1906 let target = glam::DVec3::ZERO;
1907 let view = DMat4::look_at_rh(eye, target, glam::DVec3::Z);
1908 let frustum = Frustum::from_view_projection(&(proj * view));
1909
1910 let world_size = crate::mercator::WebMercator::world_size();
1911 let cam = CoveringCamera {
1912 camera_x: 0.5,
1913 camera_y: 0.5 - 40_000.0 / world_size,
1914 camera_to_center_z: 20_000.0 / world_size,
1915 center_x: 0.5,
1916 center_y: 0.5,
1917 pitch_rad: 1.1,
1918 fov_deg: 45.0,
1919 zoom: 6.0,
1920 display_tile_size: 256,
1921 };
1922 let opts = CoveringTilesOptions {
1923 allow_variable_zoom: false,
1924 render_world_copies: false,
1925 ..CoveringTilesOptions::default()
1926 };
1927
1928 let tiles = visible_tiles_covering(&frustum, &cam, &opts);
1929 assert!(!tiles.is_empty());
1930 let first_zoom = tiles[0].zoom;
1931 assert!(
1932 tiles.iter().all(|t| t.zoom == first_zoom),
1933 "With variable zoom disabled, all tiles should be at the same zoom"
1934 );
1935 }
1936}