Skip to main content

terrain_codec/
normals.rs

1//! Vertex-normal computation for terrain meshes.
2//!
3//! Two strategies are provided:
4//!
5//! - [`face_normals`] — accumulates triangle normals onto vertices.
6//!   Simple but produces a **visible seam at tile boundaries** because
7//!   each tile only sees its own triangles.
8//! - [`buffered_gradient_normals`] — samples a buffer-extended DEM grid
9//!   that covers cells **beyond** the tile on every side. Adjacent tiles
10//!   read the same samples at any shared physical position, so seam
11//!   vertices get identical normals on both sides and lighting is
12//!   continuous.
13//!
14//! Both functions return ECEF normals (Cesium's convention for
15//! oct-encoded normals stored in quantized-mesh tiles).
16
17use quantized_mesh::coords::{WGS84_SEMI_MAJOR_AXIS, geodetic_to_ecef};
18use quantized_mesh::{QUANTIZED_MAX, QuantizedVertices, TileBounds};
19
20/// Elevations sampled on a grid that extends `buffer` cells *beyond* the
21/// tile on each side.
22///
23/// Used by [`buffered_gradient_normals`] so that adjacent tiles read the
24/// same DEM samples at any shared physical position. This is the same
25/// idea as a raster "buffer" or image "padding": a strip of neighbour
26/// data around the tile that lets edge vertices compute the same
27/// gradient that the neighbour tile would compute.
28///
29/// # Layout
30///
31/// Row-major, north → south, `(tile_grid_size + 2*buffer)` samples per
32/// side. The inner `tile_grid_size × tile_grid_size` block corresponds
33/// to the tile itself; the surrounding strip of width `buffer` cells is
34/// sampled from the **neighbour** tiles' DEM (or any DEM source covering
35/// that physical area), so vertices on the absolute tile edge can still
36/// read their `±1` neighbours via the buffer cells.
37#[derive(Debug, Clone)]
38pub struct BufferedElevations {
39    /// Elevation samples (`(tile_grid_size + 2*buffer)²` entries,
40    /// row-major north → south, NaN allowed for missing data).
41    pub elevations: Vec<f64>,
42    /// Number of buffer cells on each side.
43    pub buffer: u32,
44    /// Tile grid size (e.g. 65 for a Cesium-style 64-cell tile).
45    /// The full buffered grid is `(tile_grid_size + 2*buffer)` per side.
46    pub tile_grid_size: u32,
47}
48
49impl BufferedElevations {
50    /// Create a buffer-extended elevation grid.
51    ///
52    /// # Panics
53    ///
54    /// Panics if `elevations.len()` does not equal
55    /// `(tile_grid_size + 2*buffer)²`.
56    pub fn new(elevations: Vec<f64>, tile_grid_size: u32, buffer: u32) -> Self {
57        let side = (tile_grid_size + 2 * buffer) as usize;
58        assert_eq!(
59            elevations.len(),
60            side * side,
61            "buffered grid must have {side}×{side} = {} samples, got {}",
62            side * side,
63            elevations.len()
64        );
65        Self {
66            elevations,
67            buffer,
68            tile_grid_size,
69        }
70    }
71
72    /// Width/height of the full buffer-extended grid in samples.
73    #[inline]
74    pub fn buffered_grid_size(&self) -> u32 {
75        self.tile_grid_size + 2 * self.buffer
76    }
77}
78
79/// Compute vertex normals by accumulating triangle face normals onto each
80/// vertex (then renormalising).
81///
82/// Vertices are mapped from their quantised `(u, v, h)` back to geodetic
83/// `(lon, lat, height)` using the supplied bounds + height range, then to
84/// ECEF for the cross-product math. The returned vectors are unit-length
85/// ECEF normals — Cesium's convention for oct-encoded quantized-mesh
86/// normals.
87///
88/// **Note:** this only looks at triangles inside the current tile, so
89/// vertices on the tile edge get a normal that doesn't match the
90/// neighbour tile's view of the same physical edge. Use
91/// [`buffered_gradient_normals`] when seam-free shading matters.
92pub fn face_normals(
93    vertices: &QuantizedVertices,
94    indices: &[u32],
95    bounds: &TileBounds,
96    min_height: f64,
97    max_height: f64,
98) -> Vec<[f32; 3]> {
99    let vertex_count = vertices.len();
100    let mut normals = vec![[0.0f32; 3]; vertex_count];
101
102    // Convert quantized vertices to ECEF positions.
103    let positions: Vec<[f64; 3]> = (0..vertex_count)
104        .map(|i| {
105            let u = vertices.u[i] as f64 / QUANTIZED_MAX as f64;
106            let v = vertices.v[i] as f64 / QUANTIZED_MAX as f64;
107            let h = vertices.height[i] as f64 / QUANTIZED_MAX as f64;
108
109            let lon = bounds.west + u * (bounds.east - bounds.west);
110            let lat = bounds.south + v * (bounds.north - bounds.south);
111            let height = min_height + h * (max_height - min_height);
112
113            geodetic_to_ecef(lon, lat, height)
114        })
115        .collect();
116
117    for tri in indices.chunks(3) {
118        if tri.len() < 3 {
119            continue;
120        }
121
122        let i0 = tri[0] as usize;
123        let i1 = tri[1] as usize;
124        let i2 = tri[2] as usize;
125
126        let p0 = &positions[i0];
127        let p1 = &positions[i1];
128        let p2 = &positions[i2];
129
130        // Face normal via (p1-p0) × (p2-p0). With Martini's CCW winding
131        // (when viewed from outside the ellipsoid) this gives an
132        // outward-facing normal, which is Cesium's convention.
133        let v1 = [p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]];
134        let v2 = [p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2]];
135
136        let normal = [
137            (v1[1] * v2[2] - v1[2] * v2[1]) as f32,
138            (v1[2] * v2[0] - v1[0] * v2[2]) as f32,
139            (v1[0] * v2[1] - v1[1] * v2[0]) as f32,
140        ];
141
142        for &idx in &[i0, i1, i2] {
143            normals[idx][0] += normal[0];
144            normals[idx][1] += normal[1];
145            normals[idx][2] += normal[2];
146        }
147    }
148
149    for normal in &mut normals {
150        let len = (normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]).sqrt();
151        if len > 1e-6 {
152            normal[0] /= len;
153            normal[1] /= len;
154            normal[2] /= len;
155        } else {
156            // Defensive fallback to local-up; should not happen with a
157            // well-formed mesh.
158            *normal = [0.0, 0.0, 1.0];
159        }
160    }
161
162    normals
163}
164
165/// Compute vertex normals from the DEM gradient on a buffer-extended grid.
166///
167/// For every vertex the local east/north slope is read from the same DEM
168/// samples that the neighbour tile would read — so the same physical edge
169/// sees the same gradient from either side, eliminating the tile-boundary
170/// shading seam that [`face_normals`] exhibits.
171///
172/// # Pipeline
173///
174/// 1. Map the vertex's `(u, v)` into the buffered grid's index space.
175/// 2. Sample the buffered grid bilinearly at a half-cell stencil to get
176///    the central differences `∂h/∂x` and `∂h/∂y` (metres per metre).
177/// 3. Build a local ENU normal `(-∂h/∂x, -∂h/∂y, 1)` and rotate it into
178///    ECEF, which is what Cesium expects for oct-encoded normals.
179///
180/// The returned vectors are unit-length ECEF normals.
181pub fn buffered_gradient_normals(
182    vertices: &QuantizedVertices,
183    bounds: &TileBounds,
184    buffered: &BufferedElevations,
185) -> Vec<[f32; 3]> {
186    let vertex_count = vertices.len();
187    let buffer_cells = buffered.buffer as usize;
188    let grid_size = buffered.tile_grid_size as usize;
189    let full_grid_size = grid_size + 2 * buffer_cells;
190
191    let tile_lon_span = bounds.east - bounds.west;
192    let tile_lat_span = bounds.north - bounds.south;
193    // Cells = (grid_size - 1) inside the tile; the buffer adds
194    // `buffer_cells` cells on each side, so the per-cell step in degrees
195    // is unchanged.
196    let cell_lon_deg = tile_lon_span / (grid_size as f64 - 1.0);
197    let cell_lat_deg = tile_lat_span / (grid_size as f64 - 1.0);
198
199    // Metres per degree at the tile centre — good enough since a single
200    // small tile is small relative to Earth's radius.
201    let centre_lat = (bounds.south + bounds.north) * 0.5;
202    let m_per_deg_lat = WGS84_SEMI_MAJOR_AXIS * std::f64::consts::PI / 180.0;
203    let m_per_deg_lon = m_per_deg_lat * centre_lat.to_radians().cos();
204    let cell_m_x = cell_lon_deg * m_per_deg_lon;
205    let cell_m_y = cell_lat_deg * m_per_deg_lat;
206
207    let height_at = |fx: f64, fy: f64| -> f64 {
208        // Clamp to the buffered grid so vertices on the absolute tile edge
209        // can still read their +/-1 neighbours via the buffer cells.
210        let max_idx = (full_grid_size - 1) as f64;
211        let fx = fx.clamp(0.0, max_idx);
212        let fy = fy.clamp(0.0, max_idx);
213        let x0 = fx.floor() as usize;
214        let y0 = fy.floor() as usize;
215        let x1 = (x0 + 1).min(full_grid_size - 1);
216        let y1 = (y0 + 1).min(full_grid_size - 1);
217        let tx = fx - x0 as f64;
218        let ty = fy - y0 as f64;
219        let h00 = buffered.elevations[y0 * full_grid_size + x0];
220        let h10 = buffered.elevations[y0 * full_grid_size + x1];
221        let h01 = buffered.elevations[y1 * full_grid_size + x0];
222        let h11 = buffered.elevations[y1 * full_grid_size + x1];
223        // NaN-tolerant bilinear: fall back to any defined corner.
224        let bilerp = |a: f64, b: f64, t: f64| -> f64 {
225            if a.is_nan() {
226                b
227            } else if b.is_nan() {
228                a
229            } else {
230                a * (1.0 - t) + b * t
231            }
232        };
233        let top = bilerp(h00, h10, tx);
234        let bot = bilerp(h01, h11, tx);
235        let v = bilerp(top, bot, ty);
236        if v.is_nan() { 0.0 } else { v }
237    };
238
239    let mut normals = Vec::with_capacity(vertex_count);
240    for i in 0..vertex_count {
241        let u_norm = vertices.u[i] as f64 / QUANTIZED_MAX as f64;
242        let v_norm = vertices.v[i] as f64 / QUANTIZED_MAX as f64;
243        let lon = bounds.west + u_norm * tile_lon_span;
244        let lat = bounds.south + v_norm * tile_lat_span;
245
246        // Index space in the buffered grid:
247        //   u_norm = 0 (tile west) ↔ buffered x index `buffer_cells`
248        //   u_norm = 1 (tile east) ↔ buffered x index `buffer_cells + grid_size - 1`
249        // Row index increases SOUTHWARD (north→south layout), so v_norm = 1
250        // (tile north) maps to buffered y index `buffer_cells`.
251        let fx_center = buffer_cells as f64 + u_norm * (grid_size as f64 - 1.0);
252        let fy_center = buffer_cells as f64 + (1.0 - v_norm) * (grid_size as f64 - 1.0);
253
254        // Central differences with a half-cell step. Bilinear interpolation
255        // is exact at integer offsets, so the effective stencil width is
256        // one cell — same as the neighbour tile's view of the same point.
257        let h_west = height_at(fx_center - 0.5, fy_center);
258        let h_east = height_at(fx_center + 0.5, fy_center);
259        let h_north = height_at(fx_center, fy_center - 0.5);
260        let h_south = height_at(fx_center, fy_center + 0.5);
261
262        let dh_dx = (h_east - h_west) / cell_m_x; // east-positive
263        let dh_dy = (h_north - h_south) / cell_m_y; // north-positive
264
265        // Local ENU normal of z = h(x, y): gradient is (dh/dx, dh/dy), the
266        // outward normal is (-grad, 1). Length doesn't matter — we
267        // normalise after rotating to ECEF.
268        let nx_enu = -dh_dx;
269        let ny_enu = -dh_dy;
270        let nz_enu = 1.0;
271
272        // Rotate ENU → ECEF using the local east/north/up basis at (lat, lon).
273        let lat_rad = lat.to_radians();
274        let lon_rad = lon.to_radians();
275        let (sin_lat, cos_lat) = lat_rad.sin_cos();
276        let (sin_lon, cos_lon) = lon_rad.sin_cos();
277        // east  = (-sin_lon,           cos_lon,            0)
278        // north = (-sin_lat*cos_lon,  -sin_lat*sin_lon,    cos_lat)
279        // up    = ( cos_lat*cos_lon,   cos_lat*sin_lon,    sin_lat)
280        let ex = nx_enu * (-sin_lon) + ny_enu * (-sin_lat * cos_lon) + nz_enu * (cos_lat * cos_lon);
281        let ey = nx_enu * cos_lon + ny_enu * (-sin_lat * sin_lon) + nz_enu * (cos_lat * sin_lon);
282        let ez = ny_enu * cos_lat + nz_enu * sin_lat;
283
284        let len = (ex * ex + ey * ey + ez * ez).sqrt();
285        if len > 1e-12 {
286            normals.push([(ex / len) as f32, (ey / len) as f32, (ez / len) as f32]);
287        } else {
288            // Degenerate — should never happen since the `up` component is
289            // always ≥ cos(max slope), but be defensive.
290            normals.push([
291                (cos_lat * cos_lon) as f32,
292                (cos_lat * sin_lon) as f32,
293                sin_lat as f32,
294            ]);
295        }
296    }
297
298    normals
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    /// A perfectly tilted plane h(x, y) = a*x + b*y has a constant ENU
306    /// normal everywhere. The buffered-gradient path should reproduce that
307    /// analytical normal (within float tolerance) for every vertex.
308    #[test]
309    fn buffered_gradient_matches_analytical_plane() {
310        let grid_size = 65usize;
311        let buffer = 1usize;
312        let full_grid_size = grid_size + 2 * buffer;
313
314        // ~1 km square near Tokyo so ENU→ECEF rotation does real work.
315        let bounds = TileBounds::new(139.0, 35.0, 139.01, 35.01);
316        let centre_lat = 35.005_f64;
317
318        let m_per_deg_lat = WGS84_SEMI_MAJOR_AXIS * std::f64::consts::PI / 180.0;
319        let m_per_deg_lon = m_per_deg_lat * centre_lat.to_radians().cos();
320        let cell_lon_deg = (bounds.east - bounds.west) / (grid_size as f64 - 1.0);
321        let cell_lat_deg = (bounds.north - bounds.south) / (grid_size as f64 - 1.0);
322        let cell_m_x = cell_lon_deg * m_per_deg_lon;
323        let cell_m_y = cell_lat_deg * m_per_deg_lat;
324
325        // 5 m east-rise per cell, 3 m north-rise per cell.
326        let dh_dx = 5.0 / cell_m_x;
327        let dh_dy = 3.0 / cell_m_y;
328
329        // Build a plane on the buffered grid. Row 0 is north → larger j means
330        // smaller y in ENU, so reverse j when computing y.
331        let mut buffered_grid = Vec::with_capacity(full_grid_size * full_grid_size);
332        for j in 0..full_grid_size {
333            for i in 0..full_grid_size {
334                let x = (i as f64 - buffer as f64) * cell_m_x;
335                let y = ((full_grid_size - 1 - j) as f64 - buffer as f64) * cell_m_y;
336                buffered_grid.push(dh_dx * x + dh_dy * y + 100.0);
337            }
338        }
339
340        // Hand-construct a few vertices spread across the tile.
341        let mut vertices = QuantizedVertices::with_capacity(9);
342        for v_q in [0u16, QUANTIZED_MAX / 2, QUANTIZED_MAX] {
343            for u_q in [0u16, QUANTIZED_MAX / 2, QUANTIZED_MAX] {
344                vertices.push(u_q, v_q, 0);
345            }
346        }
347
348        let buffered_elev = BufferedElevations::new(buffered_grid, grid_size as u32, buffer as u32);
349        let normals = buffered_gradient_normals(&vertices, &bounds, &buffered_elev);
350
351        // Expected ENU normal of the plane (constant).
352        let n_enu = {
353            let v = [-dh_dx, -dh_dy, 1.0];
354            let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
355            [v[0] / len, v[1] / len, v[2] / len]
356        };
357
358        for (i, n) in normals.iter().enumerate() {
359            let u_norm = vertices.u[i] as f64 / QUANTIZED_MAX as f64;
360            let v_norm = vertices.v[i] as f64 / QUANTIZED_MAX as f64;
361            let lon = bounds.west + u_norm * (bounds.east - bounds.west);
362            let lat = bounds.south + v_norm * (bounds.north - bounds.south);
363            let lat_rad = lat.to_radians();
364            let lon_rad = lon.to_radians();
365            let (sin_lat, cos_lat) = lat_rad.sin_cos();
366            let (sin_lon, cos_lon) = lon_rad.sin_cos();
367            // Project the ECEF normal back to ENU and compare.
368            let nx = n[0] as f64;
369            let ny = n[1] as f64;
370            let nz = n[2] as f64;
371            let east_c = -sin_lon * nx + cos_lon * ny;
372            let north_c = -sin_lat * cos_lon * nx + -sin_lat * sin_lon * ny + cos_lat * nz;
373            let up_c = cos_lat * cos_lon * nx + cos_lat * sin_lon * ny + sin_lat * nz;
374            let diff =
375                (east_c - n_enu[0]).abs() + (north_c - n_enu[1]).abs() + (up_c - n_enu[2]).abs();
376            assert!(
377                diff < 1e-3,
378                "vertex {i}: ENU normal mismatch (got [{east_c}, {north_c}, {up_c}], expected {n_enu:?})",
379            );
380        }
381    }
382
383    /// Two adjacent tiles sharing an east/west edge: with buffered-gradient
384    /// normals, the seam vertices should get bit-identical normals because
385    /// they read the same DEM samples from the shared buffer strip.
386    #[test]
387    fn buffered_gradient_normals_match_across_tile_seam() {
388        let grid_size = 65usize;
389        let buffer = 1usize;
390        let full_grid_size = grid_size + 2 * buffer;
391
392        let bounds_west = TileBounds::new(139.00, 35.0, 139.01, 35.01);
393        let bounds_east = TileBounds::new(139.01, 35.0, 139.02, 35.01);
394
395        // A simple ridge — both tiles' buffers read the same analytic field
396        // at the seam, so seam normals must match.
397        let height_field =
398            |lon: f64, lat: f64| -> f64 { (lon * 100.0).sin() * 50.0 + (lat * 80.0).cos() * 30.0 };
399        let cell_lon = (bounds_west.east - bounds_west.west) / (grid_size as f64 - 1.0);
400        let cell_lat = (bounds_west.north - bounds_west.south) / (grid_size as f64 - 1.0);
401
402        let make_buffered = |b: &TileBounds| -> Vec<f64> {
403            let mut out = Vec::with_capacity(full_grid_size * full_grid_size);
404            for j in 0..full_grid_size {
405                let lat = b.north + cell_lat * buffer as f64 - (j as f64) * cell_lat;
406                for i in 0..full_grid_size {
407                    let lon = b.west - cell_lon * buffer as f64 + (i as f64) * cell_lon;
408                    out.push(height_field(lon, lat));
409                }
410            }
411            out
412        };
413
414        let buf_w =
415            BufferedElevations::new(make_buffered(&bounds_west), grid_size as u32, buffer as u32);
416        let buf_e =
417            BufferedElevations::new(make_buffered(&bounds_east), grid_size as u32, buffer as u32);
418
419        let mut v_west = QuantizedVertices::with_capacity(3);
420        let mut v_east = QuantizedVertices::with_capacity(3);
421        for v_q in [0u16, QUANTIZED_MAX / 2, QUANTIZED_MAX] {
422            v_west.push(QUANTIZED_MAX, v_q, 0);
423            v_east.push(0, v_q, 0);
424        }
425
426        let n_west = buffered_gradient_normals(&v_west, &bounds_west, &buf_w);
427        let n_east = buffered_gradient_normals(&v_east, &bounds_east, &buf_e);
428
429        for (i, (a, b)) in n_west.iter().zip(n_east.iter()).enumerate() {
430            let dot = (a[0] * b[0] + a[1] * b[1] + a[2] * b[2]) as f64;
431            assert!(
432                dot > 0.999_999,
433                "seam vertex {i}: normals diverged (dot = {dot})\nwest = {a:?}\neast = {b:?}",
434            );
435        }
436    }
437
438    #[test]
439    fn face_normals_flat_returns_up() {
440        // A flat patch at the equator/prime meridian: every face normal
441        // should point ~radially out from Earth's centre.
442        let bounds = TileBounds::new(-0.01, -0.01, 0.01, 0.01);
443        let mut vertices = QuantizedVertices::with_capacity(4);
444        vertices.push(0, 0, 0);
445        vertices.push(QUANTIZED_MAX, 0, 0);
446        vertices.push(0, QUANTIZED_MAX, 0);
447        vertices.push(QUANTIZED_MAX, QUANTIZED_MAX, 0);
448        let indices = vec![0u32, 1, 2, 1, 3, 2];
449
450        let normals = face_normals(&vertices, &indices, &bounds, 0.0, 1.0);
451        for n in &normals {
452            // Near (0°, 0°) the outward radial direction is +X.
453            assert!(n[0] > 0.99, "expected mostly-+X outward normal, got {n:?}");
454        }
455    }
456}