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(bounds: &WorldBounds, zoom: u8, view: &FlatTileView) -> Vec<TileId> {
143 visible_tiles_flat_view_with_config(bounds, zoom, view, &FlatTileSelectionConfig::default())
144}
145
146pub fn visible_tiles_flat_view_with_config(
151 bounds: &WorldBounds,
152 zoom: u8,
153 view: &FlatTileView,
154 config: &FlatTileSelectionConfig,
155) -> Vec<TileId> {
156 let mut tiles = visible_tiles(bounds, zoom);
157
158 if view.pitch <= config.footprint_pitch_threshold_rad
159 || tiles.len() <= config.footprint_min_tiles
160 {
161 return tiles;
162 }
163
164 let footprint = sampled_ground_footprint(view, config);
165 if footprint.len() < 3 {
166 return tiles;
167 }
168
169 tiles.retain(|tile| tile_intersects_ground_footprint(*tile, &footprint));
170 tiles
171}
172
173fn refine_nearby_flat_tiles(
174 tiles: Vec<TileId>,
175 zoom: u8,
176 view: &FlatTileView,
177 _footprint: &[(f64, f64)],
178) -> Vec<TileId> {
179 if zoom >= MAX_ZOOM || tiles.is_empty() {
180 return tiles;
181 }
182
183 let cam_x = view.target_world.position.x;
184 let cam_y = view.target_world.position.y;
185 let refine_radius = (view.distance * 2.5).max(60_000.0);
186 let refine_radius_sq = refine_radius * refine_radius;
187
188 let mut refined = Vec::with_capacity(tiles.len() * 3);
189 for tile in tiles {
190 let bounds = tile_bounds_world(&tile);
191 let cx = (bounds.min.position.x + bounds.max.position.x) * 0.5;
192 let cy = (bounds.min.position.y + bounds.max.position.y) * 0.5;
193 let dx = cx - cam_x;
194 let dy = cy - cam_y;
195 let dist_sq = dx * dx + dy * dy;
196
197 if dist_sq <= refine_radius_sq {
198 refined.extend_from_slice(&tile.children());
199 } else {
200 refined.push(tile);
201 }
202 }
203
204 refined.sort();
205 refined.dedup();
206 refined
207}
208
209fn sampled_ground_footprint(
210 view: &FlatTileView,
211 config: &FlatTileSelectionConfig,
212) -> Vec<(f64, f64)> {
213 let w = view.viewport_width as f64;
214 let h = view.viewport_height as f64;
215 if w <= 0.0 || h <= 0.0 {
216 return Vec::new();
217 }
218
219 let edge_steps = config.footprint_edge_steps.max(1);
220
221 let half_fov = view.fov_y / 2.0;
222 let max_angle = (view.pitch + half_fov).min(std::f64::consts::FRAC_PI_2 - 0.01);
223 let ground_far = view.distance * max_angle.tan().abs().max(1.0);
224
225 let altitude_ground_cap = view.distance * 6.0;
231 let max_ground_dist =
232 (ground_far * 2.0).min(config.max_ground_distance.max(altitude_ground_cap));
233 let altitude_sky_cap = view.distance * 4.0;
234 let max_sky_dist = (view.distance * view.pitch.tan().abs().max(1.0) * 4.0)
235 .min(config.max_sky_distance.max(altitude_sky_cap));
236
237 let mut samples = Vec::with_capacity((edge_steps + 1) * 4);
238 let mut push_hit = |px: f64, py: f64| {
239 let (origin, dir) = screen_to_ray(view, px, py);
240
241 if dir.z.abs() < 1e-12 {
242 let xy_len = (dir.x * dir.x + dir.y * dir.y).sqrt();
243 if xy_len > 1e-12 {
244 samples.push((
245 origin.x + (dir.x / xy_len) * max_sky_dist,
246 origin.y + (dir.y / xy_len) * max_sky_dist,
247 ));
248 }
249 return;
250 }
251
252 let t = -origin.z / dir.z;
253 if t < 0.0 {
254 let xy_len = (dir.x * dir.x + dir.y * dir.y).sqrt();
255 if xy_len > 1e-12 {
256 samples.push((
257 origin.x + (dir.x / xy_len) * max_sky_dist,
258 origin.y + (dir.y / xy_len) * max_sky_dist,
259 ));
260 }
261 return;
262 }
263
264 let t_clamped = t.min(max_ground_dist);
265 samples.push((origin.x + dir.x * t_clamped, origin.y + dir.y * t_clamped));
266 };
267
268 for i in 0..=edge_steps {
269 let t = i as f64 / edge_steps as f64;
270 push_hit(t * w, 0.0);
271 }
272 for i in 1..=edge_steps {
273 let t = i as f64 / edge_steps as f64;
274 push_hit(w, t * h);
275 }
276 for i in (0..edge_steps).rev() {
277 let t = i as f64 / edge_steps as f64;
278 push_hit(t * w, h);
279 }
280 for i in (1..edge_steps).rev() {
281 let t = i as f64 / edge_steps as f64;
282 push_hit(0.0, t * h);
283 }
284
285 dedupe_nearby_points(samples, 1.0)
286}
287
288fn screen_to_ray(view: &FlatTileView, px: f64, py: f64) -> (DVec3, DVec3) {
289 let w = view.viewport_width.max(1) as f64;
290 let h = view.viewport_height.max(1) as f64;
291
292 let target_world = DVec3::new(
293 view.target_world.position.x,
294 view.target_world.position.y,
295 view.target_world.position.z,
296 );
297 let view_m = view_matrix(view, target_world);
298 let proj_m = perspective_matrix(view);
299 let vp_inv = (proj_m * view_m).inverse();
300
301 let ndc_x = (2.0 * px / w) - 1.0;
302 let ndc_y = 1.0 - (2.0 * py / h);
303
304 let near_ndc = DVec4::new(ndc_x, ndc_y, -1.0, 1.0);
305 let far_ndc = DVec4::new(ndc_x, ndc_y, 1.0, 1.0);
306
307 let near_world = vp_inv * near_ndc;
308 let far_world = vp_inv * far_ndc;
309
310 if near_world.w.abs() < 1e-12 || far_world.w.abs() < 1e-12 {
311 return (DVec3::ZERO, -DVec3::Z);
312 }
313
314 let near = DVec3::new(
315 near_world.x / near_world.w,
316 near_world.y / near_world.w,
317 near_world.z / near_world.w,
318 );
319 let far = DVec3::new(
320 far_world.x / far_world.w,
321 far_world.y / far_world.w,
322 far_world.z / far_world.w,
323 );
324
325 let dir = (far - near).normalize();
326 if dir.is_nan() {
327 return (DVec3::ZERO, -DVec3::Z);
328 }
329 (near, dir)
330}
331
332fn eye_offset(view: &FlatTileView) -> DVec3 {
333 let (sp, cp) = view.pitch.sin_cos();
334 let (sy, cy) = view.yaw.sin_cos();
335 DVec3::new(
336 -view.distance * sp * sy,
337 -view.distance * sp * cy,
338 view.distance * cp,
339 )
340}
341
342fn view_matrix(view: &FlatTileView, target_world: DVec3) -> DMat4 {
343 let eye = target_world + eye_offset(view);
344 const BLEND_RAD: f64 = 0.15;
345
346 let (sy, cy) = view.yaw.sin_cos();
347 let yaw_up = DVec3::new(sy, cy, 0.0);
348 let right = DVec3::new(cy, -sy, 0.0);
349 let look = (target_world - eye).normalize_or_zero();
350 let pitched_up = right.cross(look).normalize_or_zero();
351
352 let t = (view.pitch / BLEND_RAD).clamp(0.0, 1.0);
353 let up = (pitched_up * t + yaw_up * (1.0 - t)).normalize_or_zero();
354 let up = if up.length_squared() < 0.5 {
355 DVec3::Z
356 } else {
357 up
358 };
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 = [
419 (min_x, min_y),
420 (max_x, min_y),
421 (max_x, max_y),
422 (min_x, max_y),
423 ];
424 if corners.iter().any(|&p| point_in_polygon(p, footprint)) {
425 return true;
426 }
427
428 for i in 0..footprint.len() {
429 let a = footprint[i];
430 let b = footprint[(i + 1) % footprint.len()];
431 if segment_intersects_aabb(a, b, min_x, min_y, max_x, max_y) {
432 return true;
433 }
434 }
435
436 false
437}
438
439fn point_in_polygon(point: (f64, f64), polygon: &[(f64, f64)]) -> bool {
440 let (px, py) = point;
441 let mut inside = false;
442 let mut j = polygon.len() - 1;
443 for i in 0..polygon.len() {
444 let (xi, yi) = polygon[i];
445 let (xj, yj) = polygon[j];
446 if (yi > py) != (yj > py) {
447 let denom = yj - yi;
448 if denom.abs() > 1e-12 {
449 let x_at_py = (xj - xi) * (py - yi) / denom + xi;
450 if px < x_at_py {
451 inside = !inside;
452 }
453 }
454 }
455 j = i;
456 }
457 inside
458}
459
460fn segment_intersects_aabb(
461 a: (f64, f64),
462 b: (f64, f64),
463 min_x: f64,
464 min_y: f64,
465 max_x: f64,
466 max_y: f64,
467) -> bool {
468 if (a.0 >= min_x && a.0 <= max_x && a.1 >= min_y && a.1 <= max_y)
469 || (b.0 >= min_x && b.0 <= max_x && b.1 >= min_y && b.1 <= max_y)
470 {
471 return true;
472 }
473
474 let rect = [
475 ((min_x, min_y), (max_x, min_y)),
476 ((max_x, min_y), (max_x, max_y)),
477 ((max_x, max_y), (min_x, max_y)),
478 ((min_x, max_y), (min_x, min_y)),
479 ];
480
481 rect.iter()
482 .any(|&(r0, r1)| segments_intersect(a, b, r0, r1))
483}
484
485fn segments_intersect(a1: (f64, f64), a2: (f64, f64), b1: (f64, f64), b2: (f64, f64)) -> bool {
486 fn orient(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> f64 {
487 (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0)
488 }
489 fn on_segment(a: (f64, f64), b: (f64, f64), p: (f64, f64)) -> bool {
490 p.0 >= a.0.min(b.0) - 1e-9
491 && p.0 <= a.0.max(b.0) + 1e-9
492 && p.1 >= a.1.min(b.1) - 1e-9
493 && p.1 <= a.1.max(b.1) + 1e-9
494 }
495
496 let o1 = orient(a1, a2, b1);
497 let o2 = orient(a1, a2, b2);
498 let o3 = orient(b1, b2, a1);
499 let o4 = orient(b1, b2, a2);
500
501 if (o1 > 0.0 && o2 < 0.0 || o1 < 0.0 && o2 > 0.0)
502 && (o3 > 0.0 && o4 < 0.0 || o3 < 0.0 && o4 > 0.0)
503 {
504 return true;
505 }
506
507 (o1.abs() <= 1e-9 && on_segment(a1, a2, b1))
508 || (o2.abs() <= 1e-9 && on_segment(a1, a2, b2))
509 || (o3.abs() <= 1e-9 && on_segment(b1, b2, a1))
510 || (o4.abs() <= 1e-9 && on_segment(b1, b2, a2))
511}
512
513#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
525#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
526pub struct TileId {
527 pub zoom: u8,
529 pub x: u32,
531 pub y: u32,
533}
534
535impl TileId {
536 #[inline]
545 pub fn new(zoom: u8, x: u32, y: u32) -> Self {
546 debug_assert!(zoom <= MAX_ZOOM, "zoom {zoom} exceeds maximum {MAX_ZOOM}");
547 debug_assert!(
548 x < Self::axis_tiles(zoom),
549 "x={x} out of range for zoom {zoom}"
550 );
551 debug_assert!(
552 y < Self::axis_tiles(zoom),
553 "y={y} out of range for zoom {zoom}"
554 );
555 Self { zoom, x, y }
556 }
557
558 #[inline]
562 pub fn new_checked(zoom: u8, x: u32, y: u32) -> Option<Self> {
563 if zoom > MAX_ZOOM {
564 return None;
565 }
566 let n = Self::axis_tiles(zoom);
567 if x >= n || y >= n {
568 return None;
569 }
570 Some(Self { zoom, x, y })
571 }
572
573 #[inline]
581 pub fn axis_tiles(zoom: u8) -> u32 {
582 1u32 << zoom
583 }
584
585 #[inline]
591 pub fn parent(&self) -> Option<TileId> {
592 if self.zoom == 0 {
593 None
594 } else {
595 Some(TileId {
596 zoom: self.zoom - 1,
597 x: self.x / 2,
598 y: self.y / 2,
599 })
600 }
601 }
602
603 #[inline]
614 pub fn children(&self) -> [TileId; 4] {
615 let z = self.zoom + 1;
616 let x = self.x * 2;
617 let y = self.y * 2;
618 [
619 TileId { zoom: z, x, y },
620 TileId {
621 zoom: z,
622 x: x + 1,
623 y,
624 },
625 TileId {
626 zoom: z,
627 x,
628 y: y + 1,
629 },
630 TileId {
631 zoom: z,
632 x: x + 1,
633 y: y + 1,
634 },
635 ]
636 }
637
638 pub fn quadkey(&self) -> String {
645 let mut key = String::with_capacity(self.zoom as usize);
646 for i in (1..=self.zoom).rev() {
647 let mut digit: u8 = b'0';
648 let mask = 1u32 << (i - 1);
649 if (self.x & mask) != 0 {
650 digit += 1;
651 }
652 if (self.y & mask) != 0 {
653 digit += 2;
654 }
655 key.push(digit as char);
656 }
657 key
658 }
659
660 pub fn from_quadkey(key: &str) -> Option<Self> {
669 let zoom = key.len() as u8;
670 if zoom > MAX_ZOOM {
671 return None;
672 }
673
674 let mut x = 0u32;
675 let mut y = 0u32;
676
677 for (i, ch) in key.bytes().enumerate() {
678 let bit = 1u32 << (zoom as usize - 1 - i);
679 match ch {
680 b'0' => {}
681 b'1' => x |= bit,
682 b'2' => y |= bit,
683 b'3' => {
684 x |= bit;
685 y |= bit;
686 }
687 _ => return None,
688 }
689 }
690
691 Self::new_checked(zoom, x, y)
692 }
693
694 #[inline]
709 pub fn neighbor(&self, dx: i32, dy: i32) -> Option<TileId> {
710 let n = Self::axis_tiles(self.zoom) as i64;
711 let nx = ((self.x as i64 + dx as i64) % n + n) % n;
713 let ny = self.y as i64 + dy as i64;
715 if ny < 0 || ny >= n {
716 return None;
717 }
718 Some(TileId {
719 zoom: self.zoom,
720 x: nx as u32,
721 y: ny as u32,
722 })
723 }
724}
725
726impl fmt::Display for TileId {
727 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
729 write!(f, "{}/{}/{}", self.zoom, self.x, self.y)
730 }
731}
732
733#[derive(Debug, Clone, Copy, PartialEq)]
745#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
746pub struct TileCoord {
747 pub zoom: u8,
749 pub x: f64,
751 pub y: f64,
753}
754
755impl TileCoord {
756 #[inline]
758 pub fn new(zoom: u8, x: f64, y: f64) -> Self {
759 Self { zoom, x, y }
760 }
761
762 #[inline]
768 pub fn tile_id(&self) -> TileId {
769 let n = TileId::axis_tiles(self.zoom);
770 let x = (self.x.max(0.0) as u32).min(n.saturating_sub(1));
774 let y = (self.y.max(0.0) as u32).min(n.saturating_sub(1));
775 TileId {
776 zoom: self.zoom,
777 x,
778 y,
779 }
780 }
781}
782
783impl fmt::Display for TileCoord {
784 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
785 write!(f, "{}/{:.3}/{:.3}", self.zoom, self.x, self.y)
786 }
787}
788
789pub fn geo_to_tile(geo: &GeoCoord, zoom: u8) -> TileCoord {
809 let n = TileId::axis_tiles(zoom) as f64;
810 let lat_rad = geo.lat.to_radians();
811 let x = (geo.lon + 180.0) / 360.0 * n;
812 let y = (1.0 - (lat_rad.tan() + 1.0 / lat_rad.cos()).ln() / PI) / 2.0 * n;
813 TileCoord::new(zoom, x, y)
814}
815
816pub fn geo_to_tile_checked(geo: &GeoCoord, zoom: u8) -> Option<TileCoord> {
821 if zoom > MAX_ZOOM || !geo.is_web_mercator_valid() {
822 return None;
823 }
824 Some(geo_to_tile(geo, zoom))
825}
826
827pub fn tile_to_geo(tile: &TileId) -> GeoCoord {
833 tile_xy_to_geo(tile.zoom, tile.x as f64, tile.y as f64)
834}
835
836pub fn tile_xy_to_geo(zoom: u8, x: f64, y: f64) -> GeoCoord {
843 let n = TileId::axis_tiles(zoom) as f64;
844 let lon = x / n * 360.0 - 180.0;
845 let lat_rad = (PI * (1.0 - 2.0 * y / n)).sinh().atan();
846 GeoCoord::from_lat_lon(lat_rad.to_degrees(), lon)
847}
848
849pub fn tile_bounds_world(tile: &TileId) -> WorldBounds {
855 let nw = tile_to_geo(tile);
857 let se = tile_xy_to_geo(tile.zoom, tile.x as f64 + 1.0, tile.y as f64 + 1.0);
858 let min_world = WebMercator::project(&GeoCoord::from_lat_lon(se.lat, nw.lon));
859 let max_world = WebMercator::project(&GeoCoord::from_lat_lon(nw.lat, se.lon));
860 WorldBounds::new(min_world, max_world)
861}
862
863pub fn visible_tiles(bounds: &WorldBounds, zoom: u8) -> Vec<TileId> {
885 let extent = WebMercator::max_extent();
886 let world_size = WebMercator::world_size();
887 let n = TileId::axis_tiles(zoom);
888
889 let spans_full_world_x = (bounds.max.position.x - bounds.min.position.x) >= world_size - 1.0;
892
893 let x_min_raw: i64;
894 let x_count: u32;
895 if spans_full_world_x {
896 x_min_raw = 0;
897 x_count = n;
898 } else {
899 let tile_world_width = world_size / n as f64;
900 x_min_raw = ((bounds.min.position.x + extent) / tile_world_width).floor() as i64;
901 let x_max_raw = ((bounds.max.position.x + extent) / tile_world_width).floor() as i64;
902 x_count = (x_max_raw - x_min_raw + 1).clamp(0, n as i64) as u32;
903 }
904
905 let world_y_to_tile_y = |world_y: f64| {
906 (((extent - world_y.clamp(-extent, extent)) / world_size) * n as f64)
907 .floor()
908 .clamp(0.0, n.saturating_sub(1) as f64) as u32
909 };
910
911 let y_min = world_y_to_tile_y(bounds.max.position.y);
912 let y_max = world_y_to_tile_y(bounds.min.position.y);
913
914 let mut tiles = Vec::with_capacity((x_count * (y_max - y_min + 1)) as usize);
915 for y in y_min..=y_max {
916 for i in 0..x_count {
917 let x = if spans_full_world_x {
918 i
919 } else {
920 (x_min_raw + i as i64).rem_euclid(n as i64) as u32
921 };
922 tiles.push(TileId { zoom, x, y });
923 }
924 }
925 tiles
926}
927
928pub fn visible_tiles_checked(bounds: &WorldBounds, zoom: u8) -> Option<Vec<TileId>> {
930 if zoom > MAX_ZOOM {
931 return None;
932 }
933 Some(visible_tiles(bounds, zoom))
934}
935
936pub fn visible_tiles_lod(
938 bounds: &WorldBounds,
939 base_zoom: u8,
940 camera_world: (f64, f64),
941 near_threshold: f64,
942 mid_threshold: f64,
943 max_tiles: usize,
944) -> Vec<TileId> {
945 use std::collections::HashSet;
946
947 let near_zoom = (base_zoom + 1).min(MAX_ZOOM);
948 let far_zoom = base_zoom.saturating_sub(1);
949
950 let near_tiles = visible_tiles(bounds, near_zoom);
951 let mid_tiles = visible_tiles(bounds, base_zoom);
952 let far_tiles = visible_tiles(bounds, far_zoom);
953
954 let near_sq = near_threshold * near_threshold;
955 let mid_sq = mid_threshold * mid_threshold;
956
957 fn tile_dist_sq(tile: &TileId, cam: (f64, f64)) -> f64 {
958 let b = tile_bounds_world(tile);
959 let cx = (b.min.position.x + b.max.position.x) * 0.5;
960 let cy = (b.min.position.y + b.max.position.y) * 0.5;
961 let dx = cx - cam.0;
962 let dy = cy - cam.1;
963 dx * dx + dy * dy
964 }
965
966 let mut result: Vec<(TileId, f64)> = Vec::new();
967 let mut seen = HashSet::new();
968
969 for tile in &near_tiles {
970 let d2 = tile_dist_sq(tile, camera_world);
971 if d2 <= near_sq && seen.insert(*tile) {
972 result.push((*tile, d2));
973 }
974 }
975
976 let near_parent_set: HashSet<TileId> = if near_zoom > base_zoom {
977 result.iter().filter_map(|(t, _)| t.parent()).collect()
978 } else {
979 HashSet::new()
980 };
981
982 for tile in &mid_tiles {
983 let d2 = tile_dist_sq(tile, camera_world);
984 if d2 <= mid_sq && seen.insert(*tile) {
985 if near_parent_set.contains(tile) {
986 let children = tile.children();
987 if children.iter().all(|c| seen.contains(c)) {
988 continue;
989 }
990 }
991 result.push((*tile, d2));
992 }
993 }
994
995 let mid_parent_set: HashSet<TileId> = if base_zoom > far_zoom {
996 result
997 .iter()
998 .filter(|(t, _)| t.zoom == base_zoom)
999 .filter_map(|(t, _)| t.parent())
1000 .collect()
1001 } else {
1002 HashSet::new()
1003 };
1004
1005 for tile in &far_tiles {
1006 let d2 = tile_dist_sq(tile, camera_world);
1007 if d2 > mid_sq && seen.insert(*tile) {
1008 if mid_parent_set.contains(tile) {
1009 let children = tile.children();
1010 if children.iter().all(|c| seen.contains(c)) {
1011 continue;
1012 }
1013 }
1014 result.push((*tile, d2));
1015 }
1016 }
1017
1018 result.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1019 result.truncate(max_tiles);
1020
1021 result.into_iter().map(|(t, _)| t).collect()
1022}
1023
1024pub fn visible_tiles_frustum(
1026 frustum: &crate::frustum::Frustum,
1027 target_zoom: u8,
1028 max_tiles: usize,
1029 camera_world: (f64, f64),
1030) -> Vec<TileId> {
1031 let target_zoom = target_zoom.min(MAX_ZOOM);
1032
1033 struct StackEntry {
1034 tile: TileId,
1035 fully_visible: bool,
1036 }
1037
1038 let mut stack = Vec::with_capacity(64);
1039 stack.push(StackEntry {
1040 tile: TileId::new(0, 0, 0),
1041 fully_visible: false,
1042 });
1043
1044 let mut result: Vec<(TileId, f64)> = Vec::new();
1045
1046 while let Some(entry) = stack.pop() {
1047 let tile = entry.tile;
1048 let bounds = tile_bounds_world(&tile);
1049
1050 if !entry.fully_visible {
1051 let mut all_inside = true;
1052 let mut any_outside = false;
1053 let min = bounds.min.position;
1054 let max = bounds.max.position;
1055
1056 for plane in frustum.planes() {
1057 let px = if plane.normal()[0] >= 0.0 {
1058 max.x
1059 } else {
1060 min.x
1061 };
1062 let py = if plane.normal()[1] >= 0.0 {
1063 max.y
1064 } else {
1065 min.y
1066 };
1067 let pz = if plane.normal()[2] >= 0.0 {
1068 max.z
1069 } else {
1070 min.z
1071 };
1072
1073 if plane.distance_to_point(px, py, pz) < 0.0 {
1074 any_outside = true;
1075 break;
1076 }
1077
1078 let nx = if plane.normal()[0] >= 0.0 {
1079 min.x
1080 } else {
1081 max.x
1082 };
1083 let ny = if plane.normal()[1] >= 0.0 {
1084 min.y
1085 } else {
1086 max.y
1087 };
1088 let nz = if plane.normal()[2] >= 0.0 {
1089 min.z
1090 } else {
1091 max.z
1092 };
1093
1094 if plane.distance_to_point(nx, ny, nz) < 0.0 {
1095 all_inside = false;
1096 }
1097 }
1098
1099 if any_outside {
1100 continue;
1101 }
1102
1103 if tile.zoom >= target_zoom {
1104 let cx = (bounds.min.position.x + bounds.max.position.x) * 0.5;
1105 let cy = (bounds.min.position.y + bounds.max.position.y) * 0.5;
1106 let dx = cx - camera_world.0;
1107 let dy = cy - camera_world.1;
1108 result.push((tile, dx * dx + dy * dy));
1109 continue;
1110 }
1111
1112 for child in tile.children().iter().rev() {
1113 stack.push(StackEntry {
1114 tile: *child,
1115 fully_visible: all_inside,
1116 });
1117 }
1118 } else {
1119 if tile.zoom >= target_zoom {
1120 let cx = (bounds.min.position.x + bounds.max.position.x) * 0.5;
1121 let cy = (bounds.min.position.y + bounds.max.position.y) * 0.5;
1122 let dx = cx - camera_world.0;
1123 let dy = cy - camera_world.1;
1124 result.push((tile, dx * dx + dy * dy));
1125 continue;
1126 }
1127
1128 for child in tile.children().iter().rev() {
1129 stack.push(StackEntry {
1130 tile: *child,
1131 fully_visible: true,
1132 });
1133 }
1134 }
1135 }
1136
1137 result.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1138 result.truncate(max_tiles);
1139 result.into_iter().map(|(t, _)| t).collect()
1140}
1141
1142pub fn visible_tiles_flat_view_capped(
1145 bounds: &WorldBounds,
1146 zoom: u8,
1147 view: &FlatTileView,
1148 max_tiles: usize,
1149) -> Vec<TileId> {
1150 visible_tiles_flat_view_capped_with_config(
1151 bounds,
1152 zoom,
1153 view,
1154 &FlatTileSelectionConfig::default(),
1155 max_tiles,
1156 )
1157}
1158
1159pub fn visible_tiles_flat_view_capped_with_config(
1163 bounds: &WorldBounds,
1164 zoom: u8,
1165 view: &FlatTileView,
1166 config: &FlatTileSelectionConfig,
1167 max_tiles: usize,
1168) -> Vec<TileId> {
1169 let mut tiles = visible_tiles_flat_view_with_config(bounds, zoom, view, config);
1170 if tiles.len() <= max_tiles {
1171 return tiles;
1172 }
1173
1174 let cam_x = view.target_world.position.x;
1175 let cam_y = view.target_world.position.y;
1176 tiles.sort_by(|a, b| {
1177 let ad = tile_distance_sq(*a, cam_x, cam_y);
1178 let bd = tile_distance_sq(*b, cam_x, cam_y);
1179 ad.partial_cmp(&bd)
1180 .unwrap_or(std::cmp::Ordering::Equal)
1181 .then_with(|| a.zoom.cmp(&b.zoom))
1182 .then_with(|| a.y.cmp(&b.y))
1183 .then_with(|| a.x.cmp(&b.x))
1184 });
1185 tiles.truncate(max_tiles);
1186 tiles
1187}
1188
1189fn tile_distance_sq(tile: TileId, cam_x: f64, cam_y: f64) -> f64 {
1190 let bounds = tile_bounds_world(&tile);
1191 let cx = (bounds.min.position.x + bounds.max.position.x) * 0.5;
1192 let cy = (bounds.min.position.y + bounds.max.position.y) * 0.5;
1193 let dx = cx - cam_x;
1194 let dy = cy - cam_y;
1195 dx * dx + dy * dy
1196}
1197
1198pub fn visible_tiles_flat_view_refined_capped(
1201 bounds: &WorldBounds,
1202 zoom: u8,
1203 view: &FlatTileView,
1204 max_tiles: usize,
1205) -> Vec<TileId> {
1206 visible_tiles_flat_view_refined_capped_with_config(
1207 bounds,
1208 zoom,
1209 view,
1210 &FlatTileSelectionConfig::default(),
1211 max_tiles,
1212 )
1213}
1214
1215pub fn visible_tiles_flat_view_refined_capped_with_config(
1217 bounds: &WorldBounds,
1218 zoom: u8,
1219 view: &FlatTileView,
1220 config: &FlatTileSelectionConfig,
1221 max_tiles: usize,
1222) -> Vec<TileId> {
1223 let footprint_filtered = visible_tiles_flat_view_with_config(bounds, zoom, view, config);
1224 if footprint_filtered.is_empty() {
1225 return footprint_filtered;
1226 }
1227
1228 let mut refined = footprint_filtered;
1229 if refined.len() < max_tiles
1230 && zoom < MAX_ZOOM
1231 && view.pitch > config.footprint_pitch_threshold_rad
1232 {
1233 let footprint = sampled_ground_footprint(view, config);
1234 if footprint.len() >= 3 {
1235 refined = refine_nearby_flat_tiles(refined, zoom, view, &footprint);
1236 }
1237 }
1238
1239 if refined.len() <= max_tiles {
1240 return refined;
1241 }
1242
1243 let cam_x = view.target_world.position.x;
1244 let cam_y = view.target_world.position.y;
1245 refined.sort_by(|a, b| {
1246 let ad = tile_distance_sq(*a, cam_x, cam_y);
1247 let bd = tile_distance_sq(*b, cam_x, cam_y);
1248 ad.partial_cmp(&bd)
1249 .unwrap_or(std::cmp::Ordering::Equal)
1250 .then_with(|| a.zoom.cmp(&b.zoom))
1251 .then_with(|| a.y.cmp(&b.y))
1252 .then_with(|| a.x.cmp(&b.x))
1253 });
1254 refined.truncate(max_tiles);
1255 refined
1256}
1257
1258#[derive(Debug, Clone)]
1269pub struct CoveringTilesOptions {
1270 pub min_zoom: u8,
1273 pub max_zoom: u8,
1276 pub round_zoom: bool,
1278 pub tile_size: u32,
1280 pub max_tiles: usize,
1282 pub allow_variable_zoom: bool,
1285 pub render_world_copies: bool,
1287}
1288
1289impl Default for CoveringTilesOptions {
1290 fn default() -> Self {
1291 Self {
1292 min_zoom: 0,
1293 max_zoom: MAX_ZOOM,
1294 round_zoom: false,
1295 tile_size: 256,
1296 max_tiles: 512,
1297 allow_variable_zoom: true,
1298 render_world_copies: true,
1299 }
1300 }
1301}
1302
1303#[derive(Debug, Clone, Copy)]
1310pub struct CoveringCamera {
1311 pub camera_x: f64,
1313 pub camera_y: f64,
1315 pub camera_to_center_z: f64,
1318 pub center_x: f64,
1320 pub center_y: f64,
1322 pub pitch_rad: f64,
1324 pub fov_deg: f64,
1326 pub zoom: f64,
1329 pub display_tile_size: u32,
1331}
1332
1333fn default_calculate_tile_zoom(
1346 requested_center_zoom: f64,
1347 dist_2d: f64,
1348 dist_z: f64,
1349 dist_center_3d: f64,
1350 fov_deg: f64,
1351) -> f64 {
1352 const MAX_ZOOM_LEVELS_ON_SCREEN: f64 = 9.314;
1353 const TILE_COUNT_MAX_MIN_RATIO: f64 = 3.0;
1354 const MAX_MERCATOR_HORIZON_ANGLE: f64 = 85.051129; fn scale_zoom(s: f64) -> f64 {
1357 s.log2()
1358 }
1359
1360 fn integral_cos_x_by_p(p: f64, x1: f64, x2: f64) -> f64 {
1361 let num_points = 10usize;
1362 let dx = (x2 - x1) / num_points as f64;
1363 let mut sum = 0.0;
1364 for i in 0..num_points {
1365 let x = x1 + (i as f64 + 0.5) / num_points as f64 * (x2 - x1);
1366 sum += dx * x.cos().powf(p);
1367 }
1368 sum
1369 }
1370
1371 let horizon_rad = (MAX_MERCATOR_HORIZON_ANGLE).to_radians();
1372 let fov_rad = fov_deg.to_radians();
1373
1374 let pitch_tile_loading_behavior = 2.0
1375 * ((MAX_ZOOM_LEVELS_ON_SCREEN - 1.0)
1376 / scale_zoom((horizon_rad - fov_rad).cos() / horizon_rad.cos())
1377 - 1.0);
1378
1379 let center_pitch = if dist_center_3d > 1e-15 {
1380 (dist_z / dist_center_3d).clamp(-1.0, 1.0).acos()
1381 } else {
1382 0.0
1383 };
1384
1385 let half_fov_rad = fov_rad / 2.0;
1386 let _tile_count_pitch0 =
1387 2.0 * integral_cos_x_by_p(pitch_tile_loading_behavior - 1.0, 0.0, half_fov_rad);
1388 let highest_pitch = horizon_rad.min(center_pitch + half_fov_rad);
1389 let lowest_pitch = highest_pitch.min(center_pitch - half_fov_rad);
1390 let tile_count = integral_cos_x_by_p(
1391 pitch_tile_loading_behavior - 1.0,
1392 lowest_pitch,
1393 highest_pitch,
1394 );
1395 let tile_count_pitch0 =
1396 2.0 * integral_cos_x_by_p(pitch_tile_loading_behavior - 1.0, 0.0, half_fov_rad);
1397
1398 let this_tile_pitch = if dist_z.abs() > 1e-15 {
1399 (dist_2d / dist_z).atan()
1400 } else {
1401 std::f64::consts::FRAC_PI_2
1402 };
1403 let dist_tile_3d = (dist_2d * dist_2d + dist_z * dist_z).sqrt();
1404
1405 let mut desired_z = requested_center_zoom;
1406 if dist_tile_3d > 1e-15 {
1407 desired_z += scale_zoom(dist_center_3d / dist_tile_3d / half_fov_rad.cos().max(0.5));
1408 }
1409 desired_z += pitch_tile_loading_behavior * scale_zoom(this_tile_pitch.cos()) / 2.0;
1410 desired_z -=
1411 scale_zoom((tile_count / tile_count_pitch0 / TILE_COUNT_MAX_MIN_RATIO).max(1.0)) / 2.0;
1412
1413 desired_z
1414}
1415
1416fn covering_zoom_level(cam: &CoveringCamera, opts: &CoveringTilesOptions, round: bool) -> f64 {
1420 fn scale_zoom(s: f64) -> f64 {
1421 s.log2()
1422 }
1423 let raw = cam.zoom + scale_zoom(cam.display_tile_size as f64 / opts.tile_size as f64);
1424 let z = if round { raw.round() } else { raw.floor() };
1425 z.max(0.0)
1426}
1427
1428fn dist_to_tile_2d(cx: f64, cy: f64, tx: u32, ty: u32, z: u8) -> f64 {
1430 let n = (1u64 << z as u64) as f64;
1431 let inv = 1.0 / n;
1432 let tile_min_x = tx as f64 * inv;
1433 let tile_min_y = ty as f64 * inv;
1434 let tile_max_x = (tx + 1) as f64 * inv;
1435 let tile_max_y = (ty + 1) as f64 * inv;
1436
1437 let dx = if cx < tile_min_x {
1438 tile_min_x - cx
1439 } else if cx > tile_max_x {
1440 cx - tile_max_x
1441 } else {
1442 0.0
1443 };
1444 let dy = if cy < tile_min_y {
1445 tile_min_y - cy
1446 } else if cy > tile_max_y {
1447 cy - tile_max_y
1448 } else {
1449 0.0
1450 };
1451 (dx * dx + dy * dy).sqrt()
1452}
1453
1454pub fn visible_tiles_covering(
1471 frustum: &crate::frustum::Frustum,
1472 cam: &CoveringCamera,
1473 opts: &CoveringTilesOptions,
1474) -> Vec<TileId> {
1475 let desired_z = covering_zoom_level(cam, opts, opts.round_zoom);
1476 let nominal_z = (desired_z as u8).min(opts.max_zoom);
1477 let num_tiles_f = (1u64 << nominal_z as u64) as f64;
1478
1479 let center_tx = num_tiles_f * cam.center_x;
1480 let center_ty = num_tiles_f * cam.center_y;
1481
1482 let dist_center_2d =
1483 ((cam.center_x - cam.camera_x).powi(2) + (cam.center_y - cam.camera_y).powi(2)).sqrt();
1484 let dist_z = cam.camera_to_center_z;
1485 let dist_center_3d = (dist_center_2d * dist_center_2d + dist_z * dist_z).sqrt();
1486
1487 let requested_center_zoom = cam.zoom
1488 + if cam.display_tile_size > 0 && opts.tile_size > 0 {
1489 (cam.display_tile_size as f64 / opts.tile_size as f64).log2()
1490 } else {
1491 0.0
1492 };
1493
1494 struct StackEntry {
1495 zoom: u8,
1496 x: u32,
1497 y: u32,
1498 wrap: i32,
1499 fully_visible: bool,
1500 }
1501
1502 let mut stack: Vec<StackEntry> = Vec::with_capacity(64);
1503
1504 if opts.render_world_copies {
1506 for i in 1..=3i32 {
1507 stack.push(StackEntry {
1508 zoom: 0,
1509 x: 0,
1510 y: 0,
1511 wrap: -i,
1512 fully_visible: false,
1513 });
1514 stack.push(StackEntry {
1515 zoom: 0,
1516 x: 0,
1517 y: 0,
1518 wrap: i,
1519 fully_visible: false,
1520 });
1521 }
1522 }
1523 stack.push(StackEntry {
1524 zoom: 0,
1525 x: 0,
1526 y: 0,
1527 wrap: 0,
1528 fully_visible: false,
1529 });
1530
1531 struct ResultEntry {
1532 tile: TileId,
1533 wrap: i32,
1534 dist_sq: f64,
1535 }
1536
1537 let mut result: Vec<ResultEntry> = Vec::new();
1538 let world_size = WebMercator::world_size();
1539
1540 while let Some(entry) = stack.pop() {
1541 let z = entry.zoom;
1542 let x = entry.x;
1543 let y = entry.y;
1544 let mut fully_visible = entry.fully_visible;
1545
1546 let tile_for_bounds = TileId {
1548 zoom: z,
1549 x: x % TileId::axis_tiles(z).max(1),
1550 y: y.min(TileId::axis_tiles(z).saturating_sub(1)),
1551 };
1552 let base_bounds = tile_bounds_world(&tile_for_bounds);
1553 let wrap_offset = entry.wrap as f64 * world_size;
1554 let bounds = WorldBounds::new(
1555 WorldCoord::new(
1556 base_bounds.min.position.x + wrap_offset,
1557 base_bounds.min.position.y,
1558 0.0,
1559 ),
1560 WorldCoord::new(
1561 base_bounds.max.position.x + wrap_offset,
1562 base_bounds.max.position.y,
1563 0.0,
1564 ),
1565 );
1566
1567 if !fully_visible {
1568 if !frustum.intersects_aabb(&bounds) {
1569 continue;
1570 }
1571 let min = bounds.min.position;
1573 let max = bounds.max.position;
1574 let mut all_inside = true;
1575 for plane in frustum.planes() {
1576 let nx = if plane.normal()[0] >= 0.0 {
1577 min.x
1578 } else {
1579 max.x
1580 };
1581 let ny = if plane.normal()[1] >= 0.0 {
1582 min.y
1583 } else {
1584 max.y
1585 };
1586 let nz = if plane.normal()[2] >= 0.0 {
1587 min.z
1588 } else {
1589 max.z
1590 };
1591 if plane.distance_to_point(nx, ny, nz) < 0.0 {
1592 all_inside = false;
1593 break;
1594 }
1595 }
1596 fully_visible = all_inside;
1597 }
1598
1599 let this_tile_desired_z = if opts.allow_variable_zoom && cam.pitch_rad > 0.05 {
1601 let d2d = dist_to_tile_2d(cam.camera_x, cam.camera_y, x, y, z);
1602 let z_val = default_calculate_tile_zoom(
1603 requested_center_zoom,
1604 d2d,
1605 dist_z,
1606 dist_center_3d,
1607 cam.fov_deg,
1608 );
1609 let z_rounded = if opts.round_zoom {
1610 z_val.round()
1611 } else {
1612 z_val.floor()
1613 };
1614 (z_rounded.max(0.0) as u8).min(opts.max_zoom)
1615 } else {
1616 nominal_z
1617 };
1618
1619 if z >= this_tile_desired_z {
1621 if z < opts.min_zoom {
1622 continue;
1623 }
1624 let dz_shift = nominal_z.saturating_sub(z);
1626 let tile_center_x = (x as f64 + 0.5) * (1u64 << dz_shift) as f64;
1627 let tile_center_y = (y as f64 + 0.5) * (1u64 << dz_shift) as f64;
1628 let dx = center_tx - tile_center_x;
1629 let dy = center_ty - tile_center_y;
1630
1631 let n = TileId::axis_tiles(z);
1633 let wrapped_x = if n > 0 {
1634 ((x as i64).rem_euclid(n as i64)) as u32
1635 } else {
1636 0
1637 };
1638
1639 result.push(ResultEntry {
1640 tile: TileId {
1641 zoom: z,
1642 x: wrapped_x,
1643 y: y.min(n.saturating_sub(1)),
1644 },
1645 wrap: entry.wrap,
1646 dist_sq: dx * dx + dy * dy,
1647 });
1648 continue;
1649 }
1650
1651 let child_z = z + 1;
1653 if child_z > MAX_ZOOM {
1654 continue;
1655 }
1656 for i in (0..4u32).rev() {
1657 let cx = (x << 1) + (i % 2);
1658 let cy = (y << 1) + (i >> 1);
1659 stack.push(StackEntry {
1660 zoom: child_z,
1661 x: cx,
1662 y: cy,
1663 wrap: entry.wrap,
1664 fully_visible,
1665 });
1666 }
1667 }
1668
1669 result.sort_by(|a, b| {
1677 a.dist_sq
1678 .partial_cmp(&b.dist_sq)
1679 .unwrap_or(std::cmp::Ordering::Equal)
1680 });
1681
1682 let mut seen = std::collections::HashSet::new();
1684 let mut final_tiles = Vec::with_capacity(result.len().min(opts.max_tiles));
1685 for entry in result {
1686 if seen.insert((entry.wrap, entry.tile.zoom, entry.tile.x, entry.tile.y)) {
1687 final_tiles.push(entry.tile);
1688 if final_tiles.len() >= opts.max_tiles {
1689 break;
1690 }
1691 }
1692 }
1693
1694 final_tiles
1695}
1696
1697#[cfg(test)]
1698mod tests {
1699 use super::*;
1700 use crate::mercator::WebMercator;
1701
1702 #[test]
1703 fn visible_tiles_flat_view_preserves_coverage() {
1704 let bounds = WorldBounds::new(
1705 WebMercator::project(&GeoCoord::from_lat_lon(37.7749, -122.4194)),
1706 WebMercator::project(&GeoCoord::from_lat_lon(37.8049, -122.3894)),
1707 );
1708 let zoom = 12;
1709 let view = FlatTileView::new(
1710 bounds.center(),
1711 1000.0,
1712 std::f64::consts::FRAC_PI_4,
1713 0.0,
1714 std::f64::consts::FRAC_PI_4,
1715 800,
1716 600,
1717 );
1718
1719 let tiles = visible_tiles_flat_view(&bounds, zoom, &view);
1720
1721 assert!(!tiles.is_empty());
1723 assert!(tiles.iter().all(|t| t.zoom == zoom));
1724 }
1725
1726 #[test]
1727 fn visible_tiles_flat_view_capped_does_not_exceed_limit() {
1728 let bounds = WorldBounds::new(
1729 WebMercator::project(&GeoCoord::from_lat_lon(37.7749, -122.4194)),
1730 WebMercator::project(&GeoCoord::from_lat_lon(37.8049, -122.3894)),
1731 );
1732 let zoom = 12;
1733 let view = FlatTileView::new(
1734 bounds.center(),
1735 1000.0,
1736 std::f64::consts::FRAC_PI_4,
1737 0.0,
1738 std::f64::consts::FRAC_PI_4,
1739 800,
1740 600,
1741 );
1742
1743 let max_tiles = 10;
1744 let tiles = visible_tiles_flat_view_capped(&bounds, zoom, &view, max_tiles);
1745
1746 assert!(tiles.len() <= max_tiles);
1748 }
1749
1750 #[test]
1751 fn visible_tiles_covering_top_down_returns_uniform_zoom() {
1752 use crate::frustum::Frustum;
1753 use glam::DMat4;
1754
1755 let proj = DMat4::perspective_rh(std::f64::consts::FRAC_PI_4, 1.5, 1.0, 500_000.0);
1757 let eye = glam::DVec3::new(0.0, 0.0, 50_000.0);
1758 let target = glam::DVec3::ZERO;
1759 let view = DMat4::look_at_rh(eye, target, glam::DVec3::Y);
1760 let frustum = Frustum::from_view_projection(&(proj * view));
1761
1762 let world_size = crate::mercator::WebMercator::world_size();
1763 let cam = CoveringCamera {
1764 camera_x: 0.5,
1765 camera_y: 0.5,
1766 camera_to_center_z: 50_000.0 / world_size,
1767 center_x: 0.5,
1768 center_y: 0.5,
1769 pitch_rad: 0.0,
1770 fov_deg: 45.0,
1771 zoom: 4.0,
1772 display_tile_size: 256,
1773 };
1774 let opts = CoveringTilesOptions {
1775 min_zoom: 0,
1776 max_zoom: MAX_ZOOM,
1777 round_zoom: false,
1778 tile_size: 256,
1779 max_tiles: 512,
1780 allow_variable_zoom: true,
1781 render_world_copies: false,
1782 };
1783
1784 let tiles = visible_tiles_covering(&frustum, &cam, &opts);
1785 assert!(!tiles.is_empty());
1786 let first_zoom = tiles[0].zoom;
1787 assert!(tiles.iter().all(|t| t.zoom == first_zoom));
1789 }
1790
1791 #[test]
1792 fn visible_tiles_covering_pitched_produces_variable_zoom() {
1793 use crate::frustum::Frustum;
1794 use glam::DMat4;
1795
1796 let proj = DMat4::perspective_rh(std::f64::consts::FRAC_PI_4, 1.5, 1.0, 500_000.0);
1798 let eye = glam::DVec3::new(0.0, -40_000.0, 20_000.0);
1799 let target = glam::DVec3::ZERO;
1800 let view = DMat4::look_at_rh(eye, target, glam::DVec3::Z);
1801 let frustum = Frustum::from_view_projection(&(proj * view));
1802
1803 let world_size = crate::mercator::WebMercator::world_size();
1804 let pitch_rad = 1.1; let cam = CoveringCamera {
1806 camera_x: 0.5,
1807 camera_y: 0.5 - 40_000.0 / world_size,
1808 camera_to_center_z: 20_000.0 / world_size,
1809 center_x: 0.5,
1810 center_y: 0.5,
1811 pitch_rad,
1812 fov_deg: 45.0,
1813 zoom: 8.0,
1814 display_tile_size: 256,
1815 };
1816 let opts = CoveringTilesOptions {
1817 min_zoom: 0,
1818 max_zoom: MAX_ZOOM,
1819 round_zoom: false,
1820 tile_size: 256,
1821 max_tiles: 512,
1822 allow_variable_zoom: true,
1823 render_world_copies: false,
1824 };
1825
1826 let tiles = visible_tiles_covering(&frustum, &cam, &opts);
1827 assert!(!tiles.is_empty());
1828 let zooms: std::collections::HashSet<u8> = tiles.iter().map(|t| t.zoom).collect();
1829 assert!(
1831 zooms.len() > 1,
1832 "Expected multiple zoom levels at steep pitch, got: {:?}",
1833 zooms
1834 );
1835 }
1836
1837 #[test]
1838 fn visible_tiles_covering_respects_max_tiles() {
1839 use crate::frustum::Frustum;
1840 use glam::DMat4;
1841
1842 let vp = DMat4::orthographic_rh(
1843 -500_000.0, 500_000.0, -500_000.0, 500_000.0, -500_000.0, 500_000.0,
1844 );
1845 let frustum = Frustum::from_view_projection(&vp);
1846
1847 let cam = CoveringCamera {
1848 camera_x: 0.5,
1849 camera_y: 0.5,
1850 camera_to_center_z: 0.01,
1851 center_x: 0.5,
1852 center_y: 0.5,
1853 pitch_rad: 0.0,
1854 fov_deg: 45.0,
1855 zoom: 10.0,
1856 display_tile_size: 256,
1857 };
1858 let opts = CoveringTilesOptions {
1859 min_zoom: 0,
1860 max_zoom: MAX_ZOOM,
1861 round_zoom: false,
1862 tile_size: 256,
1863 max_tiles: 5,
1864 allow_variable_zoom: false,
1865 render_world_copies: false,
1866 };
1867
1868 let tiles = visible_tiles_covering(&frustum, &cam, &opts);
1869 assert!(tiles.len() <= 5);
1870 }
1871
1872 #[test]
1873 fn visible_tiles_covering_no_duplicates() {
1874 use crate::frustum::Frustum;
1875 use glam::DMat4;
1876
1877 let proj = DMat4::perspective_rh(std::f64::consts::FRAC_PI_4, 1.5, 1.0, 500_000.0);
1878 let eye = glam::DVec3::new(0.0, 0.0, 50_000.0);
1879 let view = DMat4::look_at_rh(eye, glam::DVec3::ZERO, glam::DVec3::Y);
1880 let frustum = Frustum::from_view_projection(&(proj * view));
1881
1882 let cam = CoveringCamera {
1883 camera_x: 0.5,
1884 camera_y: 0.5,
1885 camera_to_center_z: 0.001,
1886 center_x: 0.5,
1887 center_y: 0.5,
1888 pitch_rad: 0.0,
1889 fov_deg: 45.0,
1890 zoom: 4.0,
1891 display_tile_size: 256,
1892 };
1893 let opts = CoveringTilesOptions {
1894 render_world_copies: true,
1895 ..CoveringTilesOptions::default()
1896 };
1897
1898 let tiles = visible_tiles_covering(&frustum, &cam, &opts);
1899 let unique: std::collections::HashSet<_> = tiles.iter().collect();
1900 assert_eq!(
1901 tiles.len(),
1902 unique.len(),
1903 "Covering tiles should have no duplicates"
1904 );
1905 }
1906
1907 #[test]
1908 fn high_altitude_pitched_flat_view_selects_enough_tiles() {
1909 let center = WebMercator::project(&GeoCoord::from_lat_lon(40.839, -72.9));
1913 let distance = 1_406_424.0; let pitch = 48.4_f64.to_radians();
1915 let yaw = -20.3_f64.to_radians();
1916 let fov_y = 45.0_f64.to_radians();
1917 let view = FlatTileView::new(center, distance, pitch, yaw, fov_y, 1280, 720);
1918
1919 let half = distance * 4.0;
1921 let bounds = WorldBounds::new(
1922 WorldCoord::new(center.position.x - half, center.position.y - half, 0.0),
1923 WorldCoord::new(center.position.x + half, center.position.y + half, 0.0),
1924 );
1925
1926 let tiles = visible_tiles_flat_view_with_config(
1927 &bounds,
1928 7,
1929 &view,
1930 &FlatTileSelectionConfig::default(),
1931 );
1932
1933 assert!(
1937 tiles.len() >= 10,
1938 "Expected >= 10 tiles at zoom 7 high altitude, got {}",
1939 tiles.len()
1940 );
1941 }
1942
1943 #[test]
1944 fn visible_tiles_covering_variable_zoom_disabled_gives_uniform_zoom() {
1945 use crate::frustum::Frustum;
1946 use glam::DMat4;
1947
1948 let proj = DMat4::perspective_rh(std::f64::consts::FRAC_PI_4, 1.5, 1.0, 500_000.0);
1949 let eye = glam::DVec3::new(0.0, -40_000.0, 20_000.0);
1950 let target = glam::DVec3::ZERO;
1951 let view = DMat4::look_at_rh(eye, target, glam::DVec3::Z);
1952 let frustum = Frustum::from_view_projection(&(proj * view));
1953
1954 let world_size = crate::mercator::WebMercator::world_size();
1955 let cam = CoveringCamera {
1956 camera_x: 0.5,
1957 camera_y: 0.5 - 40_000.0 / world_size,
1958 camera_to_center_z: 20_000.0 / world_size,
1959 center_x: 0.5,
1960 center_y: 0.5,
1961 pitch_rad: 1.1,
1962 fov_deg: 45.0,
1963 zoom: 6.0,
1964 display_tile_size: 256,
1965 };
1966 let opts = CoveringTilesOptions {
1967 allow_variable_zoom: false,
1968 render_world_copies: false,
1969 ..CoveringTilesOptions::default()
1970 };
1971
1972 let tiles = visible_tiles_covering(&frustum, &cam, &opts);
1973 assert!(!tiles.is_empty());
1974 let first_zoom = tiles[0].zoom;
1975 assert!(
1976 tiles.iter().all(|t| t.zoom == first_zoom),
1977 "With variable zoom disabled, all tiles should be at the same zoom"
1978 );
1979 }
1980}