Skip to main content

rustial_engine/terrain/
mesh.rs

1//! CPU-side terrain mesh generation.
2
3use crate::camera_projection::CameraProjection;
4use crate::tile_manager::TileTextureRegion;
5use rustial_math::{ElevationGrid, TileId};
6use std::sync::Arc;
7
8/// Compute zoom-dependent skirt height in meters.
9///
10/// This follows the Mapbox GL JS heuristic: the skirt should be large
11/// enough to hide seams between tiles at different LODs but small
12/// enough to avoid visible vertical faces near the camera.  The value
13/// decreases exponentially with zoom:
14///
15/// - zoom 0:  ~20 000 m
16/// - zoom 11: ~520 m
17/// - zoom 22: ~6 m
18///
19/// When `exaggeration` is greater than 1 the skirt is scaled
20/// proportionally so that amplified elevation differences are still
21/// fully hidden.
22pub 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/// GPU-sampleable elevation texture payload for a terrain tile.
47#[derive(Debug, Clone)]
48pub struct TerrainElevationTexture {
49    /// Width of the elevation texture in texels.
50    pub width: u32,
51    /// Height of the elevation texture in texels.
52    pub height: u32,
53    /// Minimum elevation in meters.
54    pub min_elev: f32,
55    /// Maximum elevation in meters.
56    pub max_elev: f32,
57    /// Elevation samples in meters, row-major, matching [`ElevationGrid`].
58    ///
59    /// Wrapped in [`Arc`] so that cloning terrain descriptors (e.g. for
60    /// the frame-key cache in `TerrainManager`) is a cheap reference-count
61    /// bump instead of a ~260 KB memcpy per tile.
62    pub data: Arc<Vec<f32>>,
63}
64
65/// Remap a logical DEM region from interior tile UV space into the
66/// full texture UV space of a border-expanded elevation grid.
67///
68/// Terrain DEM tiles are cached as `(W+2) x (H+2)` textures with a
69/// 1-sample border used for seam patching.  `TileTextureRegion`
70/// values, however, are defined over the original interior tile.  This
71/// helper shifts and scales the region so CPU and GPU terrain sampling
72/// address the correct sub-rectangle inside the expanded texture.
73pub 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/// CPU-side terrain mesh data ready for GPU upload.
102#[derive(Debug, Clone)]
103pub struct TerrainMeshData {
104    /// Tile this mesh represents.
105    pub tile: TileId,
106    /// Elevation source tile that backs this mesh.
107    ///
108    /// When terrain display zoom exceeds the DEM source max zoom, multiple
109    /// child meshes reuse a parent DEM tile. This field identifies that parent.
110    pub elevation_source_tile: TileId,
111    /// Normalized sub-region of the elevation texture to sample for this mesh.
112    ///
113    /// `FULL` when the elevation texture matches the display tile. For
114    /// overzoomed terrain meshes this is the child sub-rect inside the parent
115    /// DEM tile.
116    pub elevation_region: TileTextureRegion,
117    /// Vertex positions in world-space (f64 for precision, cast to f32 at upload).
118    ///
119    /// For the reusable-grid path these positions represent the planar base
120    /// surface before elevation displacement. Elevation is supplied separately
121    /// via [`elevation_texture`] for GPU sampling.
122    pub positions: Vec<[f64; 3]>,
123    /// Texture coordinates.
124    pub uvs: Vec<[f32; 2]>,
125    /// Surface normals.
126    ///
127    /// For reusable-grid terrain these are base-grid normals. Final terrain
128    /// shading may derive finer normals in the renderer from sampled heights.
129    pub normals: Vec<[f32; 3]>,
130    /// Triangle indices.
131    pub indices: Vec<u32>,
132    /// Generation counter from the terrain manager's elevation cache.
133    ///
134    /// Increments whenever the elevation source delivers new data.
135    /// Renderers compare this against the value stored on their entity
136    /// to detect when a mesh must be rebuilt (e.g. when a flat
137    /// placeholder is replaced by real elevation data).
138    pub generation: u64,
139    /// Grid resolution used to build the reusable terrain mesh.
140    pub grid_resolution: u16,
141    /// Vertical exaggeration already selected for this tile.
142    pub vertical_exaggeration: f32,
143    /// Optional GPU-sampleable elevation payload for this tile.
144    pub elevation_texture: Option<TerrainElevationTexture>,
145}
146
147/// Build a lightweight terrain descriptor that carries reusable-grid metadata
148/// and GPU-sampleable elevation payload without eagerly generating displaced
149/// CPU geometry.
150pub 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
168/// Build a lightweight terrain descriptor with explicit elevation-source mapping.
169pub 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    // Compute min/max from the interior of the elevation grid, excluding
179    // the 1-pixel border that may contain extreme values from neighboring
180    // ocean tiles patched via backfill.  For overzoomed tiles this also
181    // restricts to the subregion actually used by this child tile.
182    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
205/// Compute min/max elevation from a subregion of the elevation grid.
206///
207/// The grid may be border-expanded (W+2 x H+2) where the interior starts
208/// at pixel (1,1).  Texture region coordinates (0..1) refer to the
209/// original interior, so we must offset by 1 if a border is present.
210fn 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    // Detect border-expanded grids: original interior is (w-2) x (h-2),
218    // interior pixels start at (1, 1).
219    let (interior_w, interior_h, offset) = if w >= 4 && h >= 4 {
220        // Assume 1-pixel border expansion is always present when
221        // the grid is used with overzoom texture regions.
222        (w - 2, h - 2, 1usize)
223    } else {
224        (w, h, 0)
225    };
226
227    // Map normalized texture coordinates to interior pixel ranges,
228    // then shift into the full (possibly expanded) grid.
229    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
256/// Materialize displaced CPU geometry from a lightweight terrain descriptor.
257///
258/// If geometry is already present it is returned unchanged.
259pub 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
293/// Build a terrain mesh for a tile, displacing vertex Z by elevation values.
294///
295/// `resolution` is the number of vertices per edge (e.g. 64 means a 64x64 grid).
296/// `exaggeration` scales elevation values vertically.
297/// `skirt_depth` adds a vertical skirt around the tile edges.
298/// `generation` is stamped into the output so renderers can detect changes.
299pub 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/// Build a terrain mesh with explicit elevation-source mapping metadata.
322#[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    // Generate grid vertices.
345    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    // Generate indices.
366    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    // Compute normals via central differences.
384    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    // Compute a single base Z that ALL skirt vertices drop to.
461    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    // For each edge, duplicate the edge vertices at skirt_z
468    // and add triangles connecting them.
469    //
470    // Outward-facing normals per edge so the skirt receives proper
471    // hillshade lighting instead of appearing dark.
472    let edges: [(&[f32; 3], Vec<usize>); 4] = [
473        // Top edge (row 0) -- faces north (+Y).
474        (&[0.0, 1.0, 0.0], (0..res).collect()),
475        // Bottom edge (row res-1) -- faces south (-Y).
476        (&[0.0, -1.0, 0.0], ((res - 1) * res..res * res).collect()),
477        // Left edge (col 0) -- faces west (-X).
478        (&[-1.0, 0.0, 0.0], (0..res).map(|r| r * res).collect()),
479        // Right edge (col res-1) -- faces east (+X).
480        (
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            // Skirt vertex for a: same XY, dropped to skirt_z.
495            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            // Skirt vertex for b: same XY, dropped to skirt_z.
501            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            // Two triangles for the skirt quad.
507            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        // 4x4 grid = 16 vertices, 3*3*2 = 18 triangles = 54 indices.
529        assert_eq!(mesh.positions.len(), 16);
530        assert_eq!(mesh.indices.len(), 54);
531
532        // All Z should be 0 for flat grid.
533        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        // Check that the Z values are displaced.
546        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        // Flat surface: all normals should point up (Z ~ 1).
589        for n in &mesh.normals {
590            assert!(n[2] > 0.99);
591        }
592    }
593
594    #[test]
595    fn adjacent_tiles_share_edge_positions() {
596        // Two horizontally adjacent tiles at zoom 10 should have
597        // identical vertex positions along their shared edge.
598        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        // Right edge of left tile: col = 3 (last col), all rows.
624        // Left edge of right tile: col = 0 (first col), all rows.
625        let res = 4;
626        for row in 0..res {
627            let l_idx = row * res + (res - 1); // right edge of left tile
628            let r_idx = row * res; // left edge of right tile
629
630            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        // Two vertically adjacent tiles should share positions along
645        // bottom of upper / top of lower.
646        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; // bottom edge of upper tile
674            let d_idx = col; // top edge of lower tile
675
676            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        // Verify that two horizontally adjacent tiles with non-flat
691        // elevation still share exact positions along their common edge.
692        // Uses a simple slope: elev = u * 100 (increasing east).
693        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        // Left tile: elev rises from 0 (west) to 100 (east).
701        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        // Right tile starts at 100 (matching left's right edge).
707        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        // Verify that all skirt vertices share the same Z value
749        // (min_surface_z - skirt_depth) regardless of the edge vertex
750        // elevation.  This ensures adjacent tile skirts overlap.
751        let tile = TileId::new(10, 100, 100);
752        let data = vec![0.0, 100.0, 200.0, 300.0]; // sloped 2x2 grid
753        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        // Surface vertices: indices 0..4 with Z = 0, 100, 200, 300.
766        // Min surface Z = 0. Skirt base = 0 - 50 = -50.
767        let surface_count = 4; // 2x2 grid
768        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        // Right tile: flat at 1000m.
790        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        // With skirt_depth = 1200 (> 1000m height above Z=0), the right
794        // tile's skirt base = 1000 - 1200 = -200, extending below any
795        // neighbouring tile at Z=0.
796        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}