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 { lo = v; }
241            if v > hi { hi = v; }
242        }
243    }
244
245    if lo > hi {
246        (elevation.min_elev, elevation.max_elev)
247    } else {
248        (lo, hi)
249    }
250}
251
252/// Materialize displaced CPU geometry from a lightweight terrain descriptor.
253///
254/// If geometry is already present it is returned unchanged.
255pub fn materialize_terrain_mesh(
256    mesh: &TerrainMeshData,
257    projection: CameraProjection,
258    skirt_depth: f64,
259) -> TerrainMeshData {
260    if !mesh.positions.is_empty() {
261        return mesh.clone();
262    }
263
264    let Some(elevation_texture) = mesh.elevation_texture.as_ref() else {
265        return mesh.clone();
266    };
267    let Some(elevation) = ElevationGrid::from_data(
268        mesh.tile,
269        elevation_texture.width,
270        elevation_texture.height,
271        elevation_texture.data.to_vec(),
272    ) else {
273        return mesh.clone();
274    };
275
276    build_terrain_mesh_with_source(
277        &mesh.tile,
278        mesh.elevation_source_tile,
279        mesh.elevation_region,
280        &elevation,
281        projection,
282        mesh.grid_resolution,
283        mesh.vertical_exaggeration as f64,
284        skirt_depth,
285        mesh.generation,
286    )
287}
288
289/// Build a terrain mesh for a tile, displacing vertex Z by elevation values.
290///
291/// `resolution` is the number of vertices per edge (e.g. 64 means a 64x64 grid).
292/// `exaggeration` scales elevation values vertically.
293/// `skirt_depth` adds a vertical skirt around the tile edges.
294/// `generation` is stamped into the output so renderers can detect changes.
295pub fn build_terrain_mesh(
296    tile: &TileId,
297    elevation: &ElevationGrid,
298    projection: CameraProjection,
299    resolution: u16,
300    exaggeration: f64,
301    skirt_depth: f64,
302    generation: u64,
303) -> TerrainMeshData {
304    build_terrain_mesh_with_source(
305        tile,
306        *tile,
307        TileTextureRegion::FULL,
308        elevation,
309        projection,
310        resolution,
311        exaggeration,
312        skirt_depth,
313        generation,
314    )
315}
316
317/// Build a terrain mesh with explicit elevation-source mapping metadata.
318pub fn build_terrain_mesh_with_source(
319    tile: &TileId,
320    elevation_source_tile: TileId,
321    elevation_region: TileTextureRegion,
322    elevation: &ElevationGrid,
323    projection: CameraProjection,
324    resolution: u16,
325    exaggeration: f64,
326    skirt_depth: f64,
327    generation: u64,
328) -> TerrainMeshData {
329    let res = resolution as usize;
330    let vertex_count = res * res;
331    let mut positions = Vec::with_capacity(vertex_count);
332    let mut uvs = Vec::with_capacity(vertex_count);
333    let sample_region = if *tile != elevation_source_tile {
334        elevation_region_in_texture_space(
335            elevation_region,
336            elevation.width,
337            elevation.height,
338        )
339    } else {
340        elevation_region
341    };
342
343    // Generate grid vertices.
344    for row in 0..res {
345        for col in 0..res {
346            let u = col as f64 / (res - 1).max(1) as f64;
347            let v = row as f64 / (res - 1).max(1) as f64;
348            let sample_u = sample_region.u_min as f64
349                + (sample_region.u_max - sample_region.u_min) as f64 * u;
350            let sample_v = sample_region.v_min as f64
351                + (sample_region.v_max - sample_region.v_min) as f64 * v;
352
353            let raw_elev = elevation
354                .sample(sample_u, sample_v)
355                .unwrap_or(0.0)
356                .clamp(-500.0, 10_000.0);
357            let elev = raw_elev as f64 * exaggeration;
358
359            positions.push(project_tile_vertex(projection, tile, u, v, elev));
360            uvs.push([u as f32, v as f32]);
361        }
362    }
363
364    // Generate indices.
365    let quad_count = (res - 1) * (res - 1);
366    let mut indices = Vec::with_capacity(quad_count * 6);
367    for row in 0..(res - 1) {
368        for col in 0..(res - 1) {
369            let tl = (row * res + col) as u32;
370            let tr = tl + 1;
371            let bl = ((row + 1) * res + col) as u32;
372            let br = bl + 1;
373            indices.push(tl);
374            indices.push(bl);
375            indices.push(tr);
376            indices.push(tr);
377            indices.push(bl);
378            indices.push(br);
379        }
380    }
381
382    // Compute normals via central differences.
383    let mut normals = vec![[0.0f32; 3]; vertex_count];
384    for row in 0..res {
385        for col in 0..res {
386            let idx = row * res + col;
387            let left = positions[if col > 0 { idx - 1 } else { idx }];
388            let right = positions[if col < res - 1 { idx + 1 } else { idx }];
389            let down = positions[if row < res - 1 { idx + res } else { idx }];
390            let up = positions[if row > 0 { idx - res } else { idx }];
391
392            let tangent_x = [
393                (right[0] - left[0]) as f32,
394                (right[1] - left[1]) as f32,
395                (right[2] - left[2]) as f32,
396            ];
397            let tangent_y = [
398                (up[0] - down[0]) as f32,
399                (up[1] - down[1]) as f32,
400                (up[2] - down[2]) as f32,
401            ];
402
403            let nx = tangent_y[1] * tangent_x[2] - tangent_y[2] * tangent_x[1];
404            let ny = tangent_y[2] * tangent_x[0] - tangent_y[0] * tangent_x[2];
405            let nz = tangent_y[0] * tangent_x[1] - tangent_y[1] * tangent_x[0];
406            let len = (nx * nx + ny * ny + nz * nz).sqrt();
407            normals[idx] = if len > 1e-6 {
408                let mut normal = [nx / len, ny / len, nz / len];
409                if normal[2] < 0.0 {
410                    normal = [-normal[0], -normal[1], -normal[2]];
411                }
412                normal
413            } else {
414                [0.0, 0.0, 1.0]
415            };
416        }
417    }
418
419    if skirt_depth > 0.0 {
420        add_skirt(
421            &mut positions,
422            &mut uvs,
423            &mut normals,
424            &mut indices,
425            res,
426            skirt_depth,
427        );
428    }
429
430    TerrainMeshData {
431        tile: *tile,
432        elevation_source_tile,
433        elevation_region,
434        positions,
435        uvs,
436        normals,
437        indices,
438        generation,
439        grid_resolution: resolution,
440        vertical_exaggeration: exaggeration as f32,
441        elevation_texture: Some(TerrainElevationTexture {
442            width: elevation.width,
443            height: elevation.height,
444            min_elev: elevation.min_elev,
445            max_elev: elevation.max_elev,
446            data: Arc::new(elevation.data.clone()),
447        }),
448    }
449}
450
451fn add_skirt(
452    positions: &mut Vec<[f64; 3]>,
453    uvs: &mut Vec<[f32; 2]>,
454    normals: &mut Vec<[f32; 3]>,
455    indices: &mut Vec<u32>,
456    res: usize,
457    skirt_depth: f64,
458) {
459    // Compute a single base Z that ALL skirt vertices drop to.
460    let min_z = positions[..res * res]
461        .iter()
462        .map(|p| p[2])
463        .fold(f64::INFINITY, f64::min);
464    let skirt_z = min_z - skirt_depth;
465
466    // For each edge, duplicate the edge vertices at skirt_z
467    // and add triangles connecting them.
468    //
469    // Outward-facing normals per edge so the skirt receives proper
470    // hillshade lighting instead of appearing dark.
471    let edges: [(&[f32; 3], Vec<usize>); 4] = [
472        // Top edge (row 0) -- faces north (+Y).
473        (&[0.0, 1.0, 0.0], (0..res).collect()),
474        // Bottom edge (row res-1) -- faces south (-Y).
475        (&[0.0, -1.0, 0.0], ((res - 1) * res..res * res).collect()),
476        // Left edge (col 0) -- faces west (-X).
477        (&[-1.0, 0.0, 0.0], (0..res).map(|r| r * res).collect()),
478        // Right edge (col res-1) -- faces east (+X).
479        (&[1.0, 0.0, 0.0], (0..res).map(|r| r * res + res - 1).collect()),
480    ];
481
482    for (normal, edge) in &edges {
483        for i in 0..edge.len() - 1 {
484            let a = edge[i] as u32;
485            let b = edge[i + 1] as u32;
486
487            let base_a = positions.len() as u32;
488            let base_b = base_a + 1;
489
490            // Skirt vertex for a: same XY, dropped to skirt_z.
491            let pa = positions[edge[i]];
492            positions.push([pa[0], pa[1], skirt_z]);
493            uvs.push(uvs[edge[i]]);
494            normals.push(**normal);
495
496            // Skirt vertex for b: same XY, dropped to skirt_z.
497            let pb = positions[edge[i + 1]];
498            positions.push([pb[0], pb[1], skirt_z]);
499            uvs.push(uvs[edge[i + 1]]);
500            normals.push(**normal);
501
502            // Two triangles for the skirt quad.
503            indices.push(a);
504            indices.push(base_a);
505            indices.push(b);
506            indices.push(b);
507            indices.push(base_a);
508            indices.push(base_b);
509        }
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use crate::camera_projection::CameraProjection;
517
518    #[test]
519    fn flat_mesh_z_zero() {
520        let tile = TileId::new(10, 100, 100);
521        let elev = ElevationGrid::flat(tile, 4, 4);
522        let mesh = build_terrain_mesh(&tile, &elev, CameraProjection::WebMercator, 4, 1.0, 0.0, 0);
523
524        // 4x4 grid = 16 vertices, 3*3*2 = 18 triangles = 54 indices.
525        assert_eq!(mesh.positions.len(), 16);
526        assert_eq!(mesh.indices.len(), 54);
527
528        // All Z should be 0 for flat grid.
529        for pos in &mesh.positions {
530            assert!((pos[2] - 0.0).abs() < 1e-6);
531        }
532    }
533
534    #[test]
535    fn sloped_mesh_z_nonzero() {
536        let tile = TileId::new(10, 100, 100);
537        let data = vec![0.0, 100.0, 200.0, 300.0];
538        let elev = ElevationGrid::from_data(tile, 2, 2, data).unwrap();
539        let mesh = build_terrain_mesh(&tile, &elev, CameraProjection::WebMercator, 2, 1.0, 0.0, 0);
540
541        // Check that the Z values are displaced.
542        assert!((mesh.positions[0][2] - 0.0).abs() < 1e-6);
543        assert!((mesh.positions[1][2] - 100.0).abs() < 1e-6);
544    }
545
546    #[test]
547    fn exaggeration() {
548        let tile = TileId::new(10, 100, 100);
549        let data = vec![100.0; 4];
550        let elev = ElevationGrid::from_data(tile, 2, 2, data).unwrap();
551        let mesh = build_terrain_mesh(&tile, &elev, CameraProjection::WebMercator, 2, 3.0, 0.0, 0);
552
553        for pos in &mesh.positions {
554            assert!((pos[2] - 300.0).abs() < 1e-6);
555        }
556    }
557
558    #[test]
559    fn skirt_adds_vertices() {
560        let tile = TileId::new(10, 100, 100);
561        let elev = ElevationGrid::flat(tile, 4, 4);
562        let mesh_no_skirt = build_terrain_mesh(&tile, &elev, CameraProjection::WebMercator, 4, 1.0, 0.0, 0);
563        let mesh_with_skirt = build_terrain_mesh(&tile, &elev, CameraProjection::WebMercator, 4, 1.0, 100.0, 0);
564
565        assert!(mesh_with_skirt.positions.len() > mesh_no_skirt.positions.len());
566        assert!(mesh_with_skirt.indices.len() > mesh_no_skirt.indices.len());
567    }
568
569    #[test]
570    fn normals_flat_point_up() {
571        let tile = TileId::new(10, 100, 100);
572        let elev = ElevationGrid::flat(tile, 4, 4);
573        let mesh = build_terrain_mesh(&tile, &elev, CameraProjection::WebMercator, 4, 1.0, 0.0, 0);
574
575        // Flat surface: all normals should point up (Z ~ 1).
576        for n in &mesh.normals {
577            assert!(n[2] > 0.99);
578        }
579    }
580
581    #[test]
582    fn adjacent_tiles_share_edge_positions() {
583        // Two horizontally adjacent tiles at zoom 10 should have
584        // identical vertex positions along their shared edge.
585        let left = TileId::new(10, 100, 100);
586        let right = TileId::new(10, 101, 100);
587
588        let elev_l = ElevationGrid::flat(left, 4, 4);
589        let elev_r = ElevationGrid::flat(right, 4, 4);
590
591        let mesh_l = build_terrain_mesh(&left, &elev_l, CameraProjection::WebMercator, 4, 1.0, 0.0, 0);
592        let mesh_r = build_terrain_mesh(&right, &elev_r, CameraProjection::WebMercator, 4, 1.0, 0.0, 0);
593
594        // Right edge of left tile: col = 3 (last col), all rows.
595        // Left edge of right tile: col = 0 (first col), all rows.
596        let res = 4;
597        for row in 0..res {
598            let l_idx = row * res + (res - 1); // right edge of left tile
599            let r_idx = row * res;              // left edge of right tile
600
601            let l_pos = mesh_l.positions[l_idx];
602            let r_pos = mesh_r.positions[r_idx];
603
604            assert!(
605                (l_pos[0] - r_pos[0]).abs() < 1e-6
606                    && (l_pos[1] - r_pos[1]).abs() < 1e-6
607                    && (l_pos[2] - r_pos[2]).abs() < 1e-6,
608                "row {row}: left right-edge {l_pos:?} != right left-edge {r_pos:?}",
609            );
610        }
611    }
612
613    #[test]
614    fn adjacent_tiles_share_vertical_edge() {
615        // Two vertically adjacent tiles should share positions along
616        // bottom of upper / top of lower.
617        let upper = TileId::new(10, 100, 100);
618        let lower = TileId::new(10, 100, 101);
619
620        let elev_u = ElevationGrid::flat(upper, 4, 4);
621        let elev_d = ElevationGrid::flat(lower, 4, 4);
622
623        let mesh_u = build_terrain_mesh(&upper, &elev_u, CameraProjection::WebMercator, 4, 1.0, 0.0, 0);
624        let mesh_d = build_terrain_mesh(&lower, &elev_d, CameraProjection::WebMercator, 4, 1.0, 0.0, 0);
625
626        let res = 4;
627        for col in 0..res {
628            let u_idx = (res - 1) * res + col; // bottom edge of upper tile
629            let d_idx = col;                    // top edge of lower tile
630
631            let u_pos = mesh_u.positions[u_idx];
632            let d_pos = mesh_d.positions[d_idx];
633
634            assert!(
635                (u_pos[0] - d_pos[0]).abs() < 1e-6
636                    && (u_pos[1] - d_pos[1]).abs() < 1e-6
637                    && (u_pos[2] - d_pos[2]).abs() < 1e-6,
638                "col {col}: upper bottom-edge {u_pos:?} != lower top-edge {d_pos:?}",
639            );
640        }
641    }
642
643    #[test]
644    fn adjacent_tiles_share_edge_with_elevation() {
645        // Verify that two horizontally adjacent tiles with non-flat
646        // elevation still share exact positions along their common edge.
647        // Uses a simple slope: elev = u * 100 (increasing east).
648        let left = TileId::new(10, 100, 100);
649        let right = TileId::new(10, 101, 100);
650
651        let res: u16 = 4;
652        let w = res as u32;
653        let h = res as u32;
654
655        // Left tile: elev rises from 0 (west) to 100 (east).
656        let data_l: Vec<f32> = (0..h)
657            .flat_map(|_row| (0..w).map(|col| col as f32 / (w - 1) as f32 * 100.0))
658            .collect();
659        let elev_l = ElevationGrid::from_data(left, w, h, data_l).unwrap();
660
661        // Right tile starts at 100 (matching left's right edge).
662        let data_r: Vec<f32> = (0..h)
663            .flat_map(|_row| {
664                (0..w).map(|col| 100.0 + col as f32 / (w - 1) as f32 * 100.0)
665            })
666            .collect();
667        let elev_r = ElevationGrid::from_data(right, w, h, data_r).unwrap();
668
669        let mesh_l = build_terrain_mesh(&left, &elev_l, CameraProjection::WebMercator, res, 1.0, 0.0, 0);
670        let mesh_r = build_terrain_mesh(&right, &elev_r, CameraProjection::WebMercator, res, 1.0, 0.0, 0);
671
672        let r = res as usize;
673        for row in 0..r {
674            let l_idx = row * r + (r - 1);
675            let r_idx = row * r;
676
677            let l_z = mesh_l.positions[l_idx][2];
678            let r_z = mesh_r.positions[r_idx][2];
679
680            assert!(
681                (l_z - r_z).abs() < 1e-3,
682                "row {row}: left right-edge Z={l_z:.4} != right left-edge Z={r_z:.4}",
683            );
684        }
685    }
686
687    #[test]
688    fn skirt_drops_to_absolute_base() {
689        // Verify that all skirt vertices share the same Z value
690        // (min_surface_z - skirt_depth) regardless of the edge vertex
691        // elevation.  This ensures adjacent tile skirts overlap.
692        let tile = TileId::new(10, 100, 100);
693        let data = vec![0.0, 100.0, 200.0, 300.0]; // sloped 2x2 grid
694        let elev = ElevationGrid::from_data(tile, 2, 2, data).unwrap();
695        let skirt_depth = 50.0;
696        let mesh = build_terrain_mesh(&tile, &elev, CameraProjection::WebMercator, 2, 1.0, skirt_depth, 0);
697
698        // Surface vertices: indices 0..4 with Z = 0, 100, 200, 300.
699        // Min surface Z = 0. Skirt base = 0 - 50 = -50.
700        let surface_count = 4; // 2x2 grid
701        let skirt_vertices = &mesh.positions[surface_count..];
702        assert!(!skirt_vertices.is_empty(), "should have skirt vertices");
703
704        let expected_z = -50.0;
705        for (i, sv) in skirt_vertices.iter().enumerate() {
706            assert!(
707                (sv[2] - expected_z).abs() < 1e-6,
708                "skirt vertex {i}: Z={:.4}, expected {expected_z:.4}",
709                sv[2],
710            );
711        }
712    }
713
714    #[test]
715    fn adjacent_tile_skirts_overlap() {
716        let right = TileId::new(10, 101, 100);
717
718        let res: u16 = 4;
719        let w = res as u32;
720        let h = res as u32;
721
722        // Right tile: flat at 1000m.
723        let data_r = vec![1000.0f32; (w * h) as usize];
724        let elev_r = ElevationGrid::from_data(right, w, h, data_r).unwrap();
725
726        // With skirt_depth = 1200 (> 1000m height above Z=0), the right
727        // tile's skirt base = 1000 - 1200 = -200, extending below any
728        // neighbouring tile at Z=0.
729        let mesh_r_deep = build_terrain_mesh(&right, &elev_r, CameraProjection::WebMercator, res, 1.0, 1200.0, 0);
730
731        let surface_count = (res as usize) * (res as usize);
732        let skirt_z_r: Vec<f64> = mesh_r_deep.positions[surface_count..]
733            .iter()
734            .map(|p| p[2])
735            .collect();
736        assert!(
737            skirt_z_r.iter().all(|&z| z < 0.0),
738            "right tile skirt should extend below left tile surface (Z=0)"
739        );
740    }
741
742    #[test]
743    fn equirectangular_projection_changes_xy_positions() {
744        let tile = TileId::new(3, 4, 2);
745        let elev = ElevationGrid::flat(tile, 4, 4);
746        let merc = build_terrain_mesh(&tile, &elev, CameraProjection::WebMercator, 4, 1.0, 0.0, 0);
747        let eq = build_terrain_mesh(&tile, &elev, CameraProjection::Equirectangular, 4, 1.0, 0.0, 0);
748
749        assert_eq!(merc.positions.len(), eq.positions.len());
750        let different_xy = merc
751            .positions
752            .iter()
753            .zip(eq.positions.iter())
754            .any(|(a, b)| (a[0] - b[0]).abs() > 1.0 || (a[1] - b[1]).abs() > 1.0);
755        assert!(different_xy);
756    }
757
758    #[test]
759    fn elevation_region_maps_to_bordered_texture_space() {
760        let full = elevation_region_in_texture_space(TileTextureRegion::FULL, 6, 6);
761        assert!((full.u_min - 0.2).abs() < 1e-6);
762        assert!((full.v_min - 0.2).abs() < 1e-6);
763        assert!((full.u_max - 0.8).abs() < 1e-6);
764        assert!((full.v_max - 0.8).abs() < 1e-6);
765
766        let quarter = elevation_region_in_texture_space(
767            TileTextureRegion {
768                u_min: 0.0,
769                v_min: 0.0,
770                u_max: 0.5,
771                v_max: 0.5,
772            },
773            6,
774            6,
775        );
776        assert!((quarter.u_min - 0.2).abs() < 1e-6);
777        assert!((quarter.v_min - 0.2).abs() < 1e-6);
778        assert!((quarter.u_max - 0.5).abs() < 1e-6);
779        assert!((quarter.v_max - 0.5).abs() < 1e-6);
780    }
781
782    #[test]
783    fn overzoom_child_mesh_samples_only_child_region() {
784        let child = TileId::new(1, 0, 0);
785        let source = TileId::new(0, 0, 0);
786        let data = vec![
787            0.0, 0.0, 0.0, 100.0, 100.0, 100.0,
788            0.0, 0.0, 0.0, 100.0, 100.0, 100.0,
789            0.0, 0.0, 0.0, 100.0, 100.0, 100.0,
790            200.0, 200.0, 200.0, 300.0, 300.0, 300.0,
791            200.0, 200.0, 200.0, 300.0, 300.0, 300.0,
792            200.0, 200.0, 200.0, 300.0, 300.0, 300.0,
793        ];
794        let elev = ElevationGrid::from_data(source, 6, 6, data).unwrap();
795        let mesh = build_terrain_mesh_with_source(
796            &child,
797            source,
798            TileTextureRegion::from_tiles(&child, &source),
799            &elev,
800            CameraProjection::WebMercator,
801            2,
802            1.0,
803            0.0,
804            0,
805        );
806
807        let z_values: Vec<f64> = mesh.positions.iter().map(|p| p[2]).collect();
808        let expected = [0.0, 50.0, 100.0, 150.0];
809        for (actual, expected) in z_values.iter().zip(expected.iter()) {
810            assert!(
811                (actual - expected).abs() < 1e-3,
812                "child mesh should sample the top-left parent subregion, got {z_values:?}"
813            );
814        }
815    }
816}