1use crate::camera_projection::CameraProjection;
4use crate::tile_manager::TileTextureRegion;
5use rustial_math::{ElevationGrid, TileId};
6use std::sync::Arc;
7
8pub fn skirt_height(zoom: u8, exaggeration: f64) -> f64 {
23 6.0 * 1.5_f64.powi(22 - zoom as i32) * exaggeration.max(1.0)
24}
25
26fn tile_vertex_geo(tile: &TileId, u: f64, v: f64) -> rustial_math::GeoCoord {
27 let nw = rustial_math::tile_to_geo(tile);
28 let se = rustial_math::tile_xy_to_geo(tile.zoom, tile.x as f64 + 1.0, tile.y as f64 + 1.0);
29 let lat = nw.lat + (se.lat - nw.lat) * v;
30 let lon = nw.lon + (se.lon - nw.lon) * u;
31 rustial_math::GeoCoord::from_lat_lon(lat, lon)
32}
33
34fn project_tile_vertex(
35 projection: CameraProjection,
36 tile: &TileId,
37 u: f64,
38 v: f64,
39 altitude: f64,
40) -> [f64; 3] {
41 let mut geo = tile_vertex_geo(tile, u, v);
42 geo.alt = altitude;
43 projection.project_position(&geo)
44}
45
46#[derive(Debug, Clone)]
48pub struct TerrainElevationTexture {
49 pub width: u32,
51 pub height: u32,
53 pub min_elev: f32,
55 pub max_elev: f32,
57 pub data: Arc<Vec<f32>>,
63}
64
65pub fn elevation_region_in_texture_space(
74 region: TileTextureRegion,
75 width: u32,
76 height: u32,
77) -> TileTextureRegion {
78 if width < 4 || height < 4 {
79 return region;
80 }
81
82 let map_axis = |min: f32, max: f32, extent: u32| {
83 let denom = (extent - 1) as f32;
84 let interior_span = (extent - 3) as f32;
85 (
86 (1.0 + min * interior_span) / denom,
87 (1.0 + max * interior_span) / denom,
88 )
89 };
90
91 let (u_min, u_max) = map_axis(region.u_min, region.u_max, width);
92 let (v_min, v_max) = map_axis(region.v_min, region.v_max, height);
93 TileTextureRegion {
94 u_min,
95 v_min,
96 u_max,
97 v_max,
98 }
99}
100
101#[derive(Debug, Clone)]
103pub struct TerrainMeshData {
104 pub tile: TileId,
106 pub elevation_source_tile: TileId,
111 pub elevation_region: TileTextureRegion,
117 pub positions: Vec<[f64; 3]>,
123 pub uvs: Vec<[f32; 2]>,
125 pub normals: Vec<[f32; 3]>,
130 pub indices: Vec<u32>,
132 pub generation: u64,
139 pub grid_resolution: u16,
141 pub vertical_exaggeration: f32,
143 pub elevation_texture: Option<TerrainElevationTexture>,
145}
146
147pub fn build_terrain_descriptor(
151 tile: &TileId,
152 elevation: &ElevationGrid,
153 resolution: u16,
154 exaggeration: f64,
155 generation: u64,
156) -> TerrainMeshData {
157 build_terrain_descriptor_with_source(
158 tile,
159 *tile,
160 TileTextureRegion::FULL,
161 elevation,
162 resolution,
163 exaggeration,
164 generation,
165 )
166}
167
168pub fn build_terrain_descriptor_with_source(
170 tile: &TileId,
171 elevation_source_tile: TileId,
172 elevation_region: TileTextureRegion,
173 elevation: &ElevationGrid,
174 resolution: u16,
175 exaggeration: f64,
176 generation: u64,
177) -> TerrainMeshData {
178 let (min_elev, max_elev) = subregion_min_max(elevation, &elevation_region);
183
184 TerrainMeshData {
185 tile: *tile,
186 elevation_source_tile,
187 elevation_region,
188 positions: Vec::new(),
189 uvs: Vec::new(),
190 normals: Vec::new(),
191 indices: Vec::new(),
192 generation,
193 grid_resolution: resolution,
194 vertical_exaggeration: exaggeration as f32,
195 elevation_texture: Some(TerrainElevationTexture {
196 width: elevation.width,
197 height: elevation.height,
198 min_elev,
199 max_elev,
200 data: Arc::new(elevation.data.clone()),
201 }),
202 }
203}
204
205fn subregion_min_max(elevation: &ElevationGrid, region: &TileTextureRegion) -> (f32, f32) {
211 let w = elevation.width as usize;
212 let h = elevation.height as usize;
213 if w <= 2 || h <= 2 || elevation.data.is_empty() {
214 return (elevation.min_elev, elevation.max_elev);
215 }
216
217 let (interior_w, interior_h, offset) = if w >= 4 && h >= 4 {
220 (w - 2, h - 2, 1usize)
223 } else {
224 (w, h, 0)
225 };
226
227 let x0 = (region.u_min as f64 * interior_w as f64).floor() as usize + offset;
230 let x1 = ((region.u_max as f64 * interior_w as f64).ceil() as usize + offset).min(w);
231 let y0 = (region.v_min as f64 * interior_h as f64).floor() as usize + offset;
232 let y1 = ((region.v_max as f64 * interior_h as f64).ceil() as usize + offset).min(h);
233
234 let mut lo = f32::MAX;
235 let mut hi = f32::MIN;
236 for y in y0..y1 {
237 let row_start = y * w;
238 for x in x0..x1 {
239 let v = elevation.data[row_start + x];
240 if v < lo {
241 lo = v;
242 }
243 if v > hi {
244 hi = v;
245 }
246 }
247 }
248
249 if lo > hi {
250 (elevation.min_elev, elevation.max_elev)
251 } else {
252 (lo, hi)
253 }
254}
255
256pub fn materialize_terrain_mesh(
260 mesh: &TerrainMeshData,
261 projection: CameraProjection,
262 skirt_depth: f64,
263) -> TerrainMeshData {
264 if !mesh.positions.is_empty() {
265 return mesh.clone();
266 }
267
268 let Some(elevation_texture) = mesh.elevation_texture.as_ref() else {
269 return mesh.clone();
270 };
271 let Some(elevation) = ElevationGrid::from_data(
272 mesh.tile,
273 elevation_texture.width,
274 elevation_texture.height,
275 elevation_texture.data.to_vec(),
276 ) else {
277 return mesh.clone();
278 };
279
280 build_terrain_mesh_with_source(
281 &mesh.tile,
282 mesh.elevation_source_tile,
283 mesh.elevation_region,
284 &elevation,
285 projection,
286 mesh.grid_resolution,
287 mesh.vertical_exaggeration as f64,
288 skirt_depth,
289 mesh.generation,
290 )
291}
292
293pub fn build_terrain_mesh(
300 tile: &TileId,
301 elevation: &ElevationGrid,
302 projection: CameraProjection,
303 resolution: u16,
304 exaggeration: f64,
305 skirt_depth: f64,
306 generation: u64,
307) -> TerrainMeshData {
308 build_terrain_mesh_with_source(
309 tile,
310 *tile,
311 TileTextureRegion::FULL,
312 elevation,
313 projection,
314 resolution,
315 exaggeration,
316 skirt_depth,
317 generation,
318 )
319}
320
321#[allow(clippy::too_many_arguments)]
323pub fn build_terrain_mesh_with_source(
324 tile: &TileId,
325 elevation_source_tile: TileId,
326 elevation_region: TileTextureRegion,
327 elevation: &ElevationGrid,
328 projection: CameraProjection,
329 resolution: u16,
330 exaggeration: f64,
331 skirt_depth: f64,
332 generation: u64,
333) -> TerrainMeshData {
334 let res = resolution as usize;
335 let vertex_count = res * res;
336 let mut positions = Vec::with_capacity(vertex_count);
337 let mut uvs = Vec::with_capacity(vertex_count);
338 let sample_region = if *tile != elevation_source_tile {
339 elevation_region_in_texture_space(elevation_region, elevation.width, elevation.height)
340 } else {
341 elevation_region
342 };
343
344 for row in 0..res {
346 for col in 0..res {
347 let u = col as f64 / (res - 1).max(1) as f64;
348 let v = row as f64 / (res - 1).max(1) as f64;
349 let sample_u =
350 sample_region.u_min as f64 + (sample_region.u_max - sample_region.u_min) as f64 * u;
351 let sample_v =
352 sample_region.v_min as f64 + (sample_region.v_max - sample_region.v_min) as f64 * v;
353
354 let raw_elev = elevation
355 .sample(sample_u, sample_v)
356 .unwrap_or(0.0)
357 .clamp(-500.0, 10_000.0);
358 let elev = raw_elev as f64 * exaggeration;
359
360 positions.push(project_tile_vertex(projection, tile, u, v, elev));
361 uvs.push([u as f32, v as f32]);
362 }
363 }
364
365 let quad_count = (res - 1) * (res - 1);
367 let mut indices = Vec::with_capacity(quad_count * 6);
368 for row in 0..(res - 1) {
369 for col in 0..(res - 1) {
370 let tl = (row * res + col) as u32;
371 let tr = tl + 1;
372 let bl = ((row + 1) * res + col) as u32;
373 let br = bl + 1;
374 indices.push(tl);
375 indices.push(bl);
376 indices.push(tr);
377 indices.push(tr);
378 indices.push(bl);
379 indices.push(br);
380 }
381 }
382
383 let mut normals = vec![[0.0f32; 3]; vertex_count];
385 for row in 0..res {
386 for col in 0..res {
387 let idx = row * res + col;
388 let left = positions[if col > 0 { idx - 1 } else { idx }];
389 let right = positions[if col < res - 1 { idx + 1 } else { idx }];
390 let down = positions[if row < res - 1 { idx + res } else { idx }];
391 let up = positions[if row > 0 { idx - res } else { idx }];
392
393 let tangent_x = [
394 (right[0] - left[0]) as f32,
395 (right[1] - left[1]) as f32,
396 (right[2] - left[2]) as f32,
397 ];
398 let tangent_y = [
399 (up[0] - down[0]) as f32,
400 (up[1] - down[1]) as f32,
401 (up[2] - down[2]) as f32,
402 ];
403
404 let nx = tangent_y[1] * tangent_x[2] - tangent_y[2] * tangent_x[1];
405 let ny = tangent_y[2] * tangent_x[0] - tangent_y[0] * tangent_x[2];
406 let nz = tangent_y[0] * tangent_x[1] - tangent_y[1] * tangent_x[0];
407 let len = (nx * nx + ny * ny + nz * nz).sqrt();
408 normals[idx] = if len > 1e-6 {
409 let mut normal = [nx / len, ny / len, nz / len];
410 if normal[2] < 0.0 {
411 normal = [-normal[0], -normal[1], -normal[2]];
412 }
413 normal
414 } else {
415 [0.0, 0.0, 1.0]
416 };
417 }
418 }
419
420 if skirt_depth > 0.0 {
421 add_skirt(
422 &mut positions,
423 &mut uvs,
424 &mut normals,
425 &mut indices,
426 res,
427 skirt_depth,
428 );
429 }
430
431 TerrainMeshData {
432 tile: *tile,
433 elevation_source_tile,
434 elevation_region,
435 positions,
436 uvs,
437 normals,
438 indices,
439 generation,
440 grid_resolution: resolution,
441 vertical_exaggeration: exaggeration as f32,
442 elevation_texture: Some(TerrainElevationTexture {
443 width: elevation.width,
444 height: elevation.height,
445 min_elev: elevation.min_elev,
446 max_elev: elevation.max_elev,
447 data: Arc::new(elevation.data.clone()),
448 }),
449 }
450}
451
452fn add_skirt(
453 positions: &mut Vec<[f64; 3]>,
454 uvs: &mut Vec<[f32; 2]>,
455 normals: &mut Vec<[f32; 3]>,
456 indices: &mut Vec<u32>,
457 res: usize,
458 skirt_depth: f64,
459) {
460 let min_z = positions[..res * res]
462 .iter()
463 .map(|p| p[2])
464 .fold(f64::INFINITY, f64::min);
465 let skirt_z = min_z - skirt_depth;
466
467 let edges: [(&[f32; 3], Vec<usize>); 4] = [
473 (&[0.0, 1.0, 0.0], (0..res).collect()),
475 (&[0.0, -1.0, 0.0], ((res - 1) * res..res * res).collect()),
477 (&[-1.0, 0.0, 0.0], (0..res).map(|r| r * res).collect()),
479 (
481 &[1.0, 0.0, 0.0],
482 (0..res).map(|r| r * res + res - 1).collect(),
483 ),
484 ];
485
486 for (normal, edge) in &edges {
487 for i in 0..edge.len() - 1 {
488 let a = edge[i] as u32;
489 let b = edge[i + 1] as u32;
490
491 let base_a = positions.len() as u32;
492 let base_b = base_a + 1;
493
494 let pa = positions[edge[i]];
496 positions.push([pa[0], pa[1], skirt_z]);
497 uvs.push(uvs[edge[i]]);
498 normals.push(**normal);
499
500 let pb = positions[edge[i + 1]];
502 positions.push([pb[0], pb[1], skirt_z]);
503 uvs.push(uvs[edge[i + 1]]);
504 normals.push(**normal);
505
506 indices.push(a);
508 indices.push(base_a);
509 indices.push(b);
510 indices.push(b);
511 indices.push(base_a);
512 indices.push(base_b);
513 }
514 }
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520 use crate::camera_projection::CameraProjection;
521
522 #[test]
523 fn flat_mesh_z_zero() {
524 let tile = TileId::new(10, 100, 100);
525 let elev = ElevationGrid::flat(tile, 4, 4);
526 let mesh = build_terrain_mesh(&tile, &elev, CameraProjection::WebMercator, 4, 1.0, 0.0, 0);
527
528 assert_eq!(mesh.positions.len(), 16);
530 assert_eq!(mesh.indices.len(), 54);
531
532 for pos in &mesh.positions {
534 assert!((pos[2] - 0.0).abs() < 1e-6);
535 }
536 }
537
538 #[test]
539 fn sloped_mesh_z_nonzero() {
540 let tile = TileId::new(10, 100, 100);
541 let data = vec![0.0, 100.0, 200.0, 300.0];
542 let elev = ElevationGrid::from_data(tile, 2, 2, data).unwrap();
543 let mesh = build_terrain_mesh(&tile, &elev, CameraProjection::WebMercator, 2, 1.0, 0.0, 0);
544
545 assert!((mesh.positions[0][2] - 0.0).abs() < 1e-6);
547 assert!((mesh.positions[1][2] - 100.0).abs() < 1e-6);
548 }
549
550 #[test]
551 fn exaggeration() {
552 let tile = TileId::new(10, 100, 100);
553 let data = vec![100.0; 4];
554 let elev = ElevationGrid::from_data(tile, 2, 2, data).unwrap();
555 let mesh = build_terrain_mesh(&tile, &elev, CameraProjection::WebMercator, 2, 3.0, 0.0, 0);
556
557 for pos in &mesh.positions {
558 assert!((pos[2] - 300.0).abs() < 1e-6);
559 }
560 }
561
562 #[test]
563 fn skirt_adds_vertices() {
564 let tile = TileId::new(10, 100, 100);
565 let elev = ElevationGrid::flat(tile, 4, 4);
566 let mesh_no_skirt =
567 build_terrain_mesh(&tile, &elev, CameraProjection::WebMercator, 4, 1.0, 0.0, 0);
568 let mesh_with_skirt = build_terrain_mesh(
569 &tile,
570 &elev,
571 CameraProjection::WebMercator,
572 4,
573 1.0,
574 100.0,
575 0,
576 );
577
578 assert!(mesh_with_skirt.positions.len() > mesh_no_skirt.positions.len());
579 assert!(mesh_with_skirt.indices.len() > mesh_no_skirt.indices.len());
580 }
581
582 #[test]
583 fn normals_flat_point_up() {
584 let tile = TileId::new(10, 100, 100);
585 let elev = ElevationGrid::flat(tile, 4, 4);
586 let mesh = build_terrain_mesh(&tile, &elev, CameraProjection::WebMercator, 4, 1.0, 0.0, 0);
587
588 for n in &mesh.normals {
590 assert!(n[2] > 0.99);
591 }
592 }
593
594 #[test]
595 fn adjacent_tiles_share_edge_positions() {
596 let left = TileId::new(10, 100, 100);
599 let right = TileId::new(10, 101, 100);
600
601 let elev_l = ElevationGrid::flat(left, 4, 4);
602 let elev_r = ElevationGrid::flat(right, 4, 4);
603
604 let mesh_l = build_terrain_mesh(
605 &left,
606 &elev_l,
607 CameraProjection::WebMercator,
608 4,
609 1.0,
610 0.0,
611 0,
612 );
613 let mesh_r = build_terrain_mesh(
614 &right,
615 &elev_r,
616 CameraProjection::WebMercator,
617 4,
618 1.0,
619 0.0,
620 0,
621 );
622
623 let res = 4;
626 for row in 0..res {
627 let l_idx = row * res + (res - 1); let r_idx = row * res; let l_pos = mesh_l.positions[l_idx];
631 let r_pos = mesh_r.positions[r_idx];
632
633 assert!(
634 (l_pos[0] - r_pos[0]).abs() < 1e-6
635 && (l_pos[1] - r_pos[1]).abs() < 1e-6
636 && (l_pos[2] - r_pos[2]).abs() < 1e-6,
637 "row {row}: left right-edge {l_pos:?} != right left-edge {r_pos:?}",
638 );
639 }
640 }
641
642 #[test]
643 fn adjacent_tiles_share_vertical_edge() {
644 let upper = TileId::new(10, 100, 100);
647 let lower = TileId::new(10, 100, 101);
648
649 let elev_u = ElevationGrid::flat(upper, 4, 4);
650 let elev_d = ElevationGrid::flat(lower, 4, 4);
651
652 let mesh_u = build_terrain_mesh(
653 &upper,
654 &elev_u,
655 CameraProjection::WebMercator,
656 4,
657 1.0,
658 0.0,
659 0,
660 );
661 let mesh_d = build_terrain_mesh(
662 &lower,
663 &elev_d,
664 CameraProjection::WebMercator,
665 4,
666 1.0,
667 0.0,
668 0,
669 );
670
671 let res = 4;
672 for col in 0..res {
673 let u_idx = (res - 1) * res + col; let d_idx = col; let u_pos = mesh_u.positions[u_idx];
677 let d_pos = mesh_d.positions[d_idx];
678
679 assert!(
680 (u_pos[0] - d_pos[0]).abs() < 1e-6
681 && (u_pos[1] - d_pos[1]).abs() < 1e-6
682 && (u_pos[2] - d_pos[2]).abs() < 1e-6,
683 "col {col}: upper bottom-edge {u_pos:?} != lower top-edge {d_pos:?}",
684 );
685 }
686 }
687
688 #[test]
689 fn adjacent_tiles_share_edge_with_elevation() {
690 let left = TileId::new(10, 100, 100);
694 let right = TileId::new(10, 101, 100);
695
696 let res: u16 = 4;
697 let w = res as u32;
698 let h = res as u32;
699
700 let data_l: Vec<f32> = (0..h)
702 .flat_map(|_row| (0..w).map(|col| col as f32 / (w - 1) as f32 * 100.0))
703 .collect();
704 let elev_l = ElevationGrid::from_data(left, w, h, data_l).unwrap();
705
706 let data_r: Vec<f32> = (0..h)
708 .flat_map(|_row| (0..w).map(|col| 100.0 + col as f32 / (w - 1) as f32 * 100.0))
709 .collect();
710 let elev_r = ElevationGrid::from_data(right, w, h, data_r).unwrap();
711
712 let mesh_l = build_terrain_mesh(
713 &left,
714 &elev_l,
715 CameraProjection::WebMercator,
716 res,
717 1.0,
718 0.0,
719 0,
720 );
721 let mesh_r = build_terrain_mesh(
722 &right,
723 &elev_r,
724 CameraProjection::WebMercator,
725 res,
726 1.0,
727 0.0,
728 0,
729 );
730
731 let r = res as usize;
732 for row in 0..r {
733 let l_idx = row * r + (r - 1);
734 let r_idx = row * r;
735
736 let l_z = mesh_l.positions[l_idx][2];
737 let r_z = mesh_r.positions[r_idx][2];
738
739 assert!(
740 (l_z - r_z).abs() < 1e-3,
741 "row {row}: left right-edge Z={l_z:.4} != right left-edge Z={r_z:.4}",
742 );
743 }
744 }
745
746 #[test]
747 fn skirt_drops_to_absolute_base() {
748 let tile = TileId::new(10, 100, 100);
752 let data = vec![0.0, 100.0, 200.0, 300.0]; let elev = ElevationGrid::from_data(tile, 2, 2, data).unwrap();
754 let skirt_depth = 50.0;
755 let mesh = build_terrain_mesh(
756 &tile,
757 &elev,
758 CameraProjection::WebMercator,
759 2,
760 1.0,
761 skirt_depth,
762 0,
763 );
764
765 let surface_count = 4; let skirt_vertices = &mesh.positions[surface_count..];
769 assert!(!skirt_vertices.is_empty(), "should have skirt vertices");
770
771 let expected_z = -50.0;
772 for (i, sv) in skirt_vertices.iter().enumerate() {
773 assert!(
774 (sv[2] - expected_z).abs() < 1e-6,
775 "skirt vertex {i}: Z={:.4}, expected {expected_z:.4}",
776 sv[2],
777 );
778 }
779 }
780
781 #[test]
782 fn adjacent_tile_skirts_overlap() {
783 let right = TileId::new(10, 101, 100);
784
785 let res: u16 = 4;
786 let w = res as u32;
787 let h = res as u32;
788
789 let data_r = vec![1000.0f32; (w * h) as usize];
791 let elev_r = ElevationGrid::from_data(right, w, h, data_r).unwrap();
792
793 let mesh_r_deep = build_terrain_mesh(
797 &right,
798 &elev_r,
799 CameraProjection::WebMercator,
800 res,
801 1.0,
802 1200.0,
803 0,
804 );
805
806 let surface_count = (res as usize) * (res as usize);
807 let skirt_z_r: Vec<f64> = mesh_r_deep.positions[surface_count..]
808 .iter()
809 .map(|p| p[2])
810 .collect();
811 assert!(
812 skirt_z_r.iter().all(|&z| z < 0.0),
813 "right tile skirt should extend below left tile surface (Z=0)"
814 );
815 }
816
817 #[test]
818 fn equirectangular_projection_changes_xy_positions() {
819 let tile = TileId::new(3, 4, 2);
820 let elev = ElevationGrid::flat(tile, 4, 4);
821 let merc = build_terrain_mesh(&tile, &elev, CameraProjection::WebMercator, 4, 1.0, 0.0, 0);
822 let eq = build_terrain_mesh(
823 &tile,
824 &elev,
825 CameraProjection::Equirectangular,
826 4,
827 1.0,
828 0.0,
829 0,
830 );
831
832 assert_eq!(merc.positions.len(), eq.positions.len());
833 let different_xy = merc
834 .positions
835 .iter()
836 .zip(eq.positions.iter())
837 .any(|(a, b)| (a[0] - b[0]).abs() > 1.0 || (a[1] - b[1]).abs() > 1.0);
838 assert!(different_xy);
839 }
840
841 #[test]
842 fn elevation_region_maps_to_bordered_texture_space() {
843 let full = elevation_region_in_texture_space(TileTextureRegion::FULL, 6, 6);
844 assert!((full.u_min - 0.2).abs() < 1e-6);
845 assert!((full.v_min - 0.2).abs() < 1e-6);
846 assert!((full.u_max - 0.8).abs() < 1e-6);
847 assert!((full.v_max - 0.8).abs() < 1e-6);
848
849 let quarter = elevation_region_in_texture_space(
850 TileTextureRegion {
851 u_min: 0.0,
852 v_min: 0.0,
853 u_max: 0.5,
854 v_max: 0.5,
855 },
856 6,
857 6,
858 );
859 assert!((quarter.u_min - 0.2).abs() < 1e-6);
860 assert!((quarter.v_min - 0.2).abs() < 1e-6);
861 assert!((quarter.u_max - 0.5).abs() < 1e-6);
862 assert!((quarter.v_max - 0.5).abs() < 1e-6);
863 }
864
865 #[test]
866 fn overzoom_child_mesh_samples_only_child_region() {
867 let child = TileId::new(1, 0, 0);
868 let source = TileId::new(0, 0, 0);
869 let data = vec![
870 0.0, 0.0, 0.0, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0,
871 100.0, 100.0, 100.0, 200.0, 200.0, 200.0, 300.0, 300.0, 300.0, 200.0, 200.0, 200.0,
872 300.0, 300.0, 300.0, 200.0, 200.0, 200.0, 300.0, 300.0, 300.0,
873 ];
874 let elev = ElevationGrid::from_data(source, 6, 6, data).unwrap();
875 let mesh = build_terrain_mesh_with_source(
876 &child,
877 source,
878 TileTextureRegion::from_tiles(&child, &source),
879 &elev,
880 CameraProjection::WebMercator,
881 2,
882 1.0,
883 0.0,
884 0,
885 );
886
887 let z_values: Vec<f64> = mesh.positions.iter().map(|p| p[2]).collect();
888 let expected = [0.0, 50.0, 100.0, 150.0];
889 for (actual, expected) in z_values.iter().zip(expected.iter()) {
890 assert!(
891 (actual - expected).abs() < 1e-3,
892 "child mesh should sample the top-left parent subregion, got {z_values:?}"
893 );
894 }
895 }
896}