Skip to main content

quantized_mesh/
header.rs

1//! Header structures for quantized-mesh format.
2
3use crate::TileBounds;
4use crate::coords::{
5    WGS84_SEMI_MAJOR_AXIS, ecef_to_ellipsoid_scaled, geodetic_to_ecef, vec3_distance,
6    vec3_magnitude, vec3_normalize,
7};
8
9/// Quantized mesh header (88 bytes).
10///
11/// All coordinates are in Earth-Centered Earth-Fixed (ECEF) frame.
12#[derive(Debug, Clone, Copy)]
13pub struct QuantizedMeshHeader {
14    /// Center of the tile in ECEF coordinates (meters)
15    pub center: [f64; 3],
16    /// Minimum height in the tile (meters)
17    pub min_height: f32,
18    /// Maximum height in the tile (meters)
19    pub max_height: f32,
20    /// Bounding sphere center in ECEF coordinates (meters)
21    pub bounding_sphere_center: [f64; 3],
22    /// Bounding sphere radius (meters)
23    pub bounding_sphere_radius: f64,
24    /// Horizon occlusion point in ellipsoid-scaled ECEF coordinates
25    pub horizon_occlusion_point: [f64; 3],
26}
27
28impl Default for QuantizedMeshHeader {
29    fn default() -> Self {
30        Self {
31            center: [0.0, 0.0, WGS84_SEMI_MAJOR_AXIS],
32            min_height: 0.0,
33            max_height: 0.0,
34            bounding_sphere_center: [0.0, 0.0, WGS84_SEMI_MAJOR_AXIS],
35            bounding_sphere_radius: 0.0,
36            horizon_occlusion_point: [0.0, 0.0, 1.0],
37        }
38    }
39}
40
41impl QuantizedMeshHeader {
42    /// Create a header from tile bounds and height range.
43    ///
44    /// Computes ECEF coordinates, bounding sphere, and horizon occlusion point.
45    /// Falls back to corner+edge sample points for the horizon occlusion — callers
46    /// that have the actual mesh vertices should prefer `from_bounds_with_vertices`
47    /// for tighter occlusion.
48    pub fn from_bounds(bounds: &TileBounds, min_height: f32, max_height: f32) -> Self {
49        Self::from_bounds_with_vertices(bounds, min_height, max_height, &[])
50    }
51
52    /// Like `from_bounds`, but uses the supplied mesh vertices (geodetic
53    /// `(lon, lat, height)`) to compute a tighter horizon occlusion point via
54    /// the Cesium `EllipsoidalOccluder` algorithm.
55    pub fn from_bounds_with_vertices(
56        bounds: &TileBounds,
57        min_height: f32,
58        max_height: f32,
59        vertices_geodetic: &[(f64, f64, f64)],
60    ) -> Self {
61        let center_lon = bounds.center_lon();
62        let center_lat = bounds.center_lat();
63        let center_height = (min_height as f64 + max_height as f64) / 2.0;
64
65        let center = geodetic_to_ecef(center_lon, center_lat, center_height);
66
67        let (bounding_sphere_center, bounding_sphere_radius) =
68            compute_bounding_sphere(bounds, min_height as f64, max_height as f64);
69
70        let horizon_occlusion_point = compute_horizon_occlusion_point(
71            &bounding_sphere_center,
72            bounds,
73            min_height as f64,
74            max_height as f64,
75            vertices_geodetic,
76        );
77
78        Self {
79            center,
80            min_height,
81            max_height,
82            bounding_sphere_center,
83            bounding_sphere_radius,
84            horizon_occlusion_point,
85        }
86    }
87
88    /// Serialize header to bytes (88 bytes, little-endian).
89    pub fn to_bytes(&self) -> [u8; 88] {
90        let mut bytes = [0u8; 88];
91        let mut offset = 0;
92
93        // Center (3 x f64 = 24 bytes)
94        for &v in &self.center {
95            bytes[offset..offset + 8].copy_from_slice(&v.to_le_bytes());
96            offset += 8;
97        }
98
99        // Min/Max height (2 x f32 = 8 bytes)
100        bytes[offset..offset + 4].copy_from_slice(&self.min_height.to_le_bytes());
101        offset += 4;
102        bytes[offset..offset + 4].copy_from_slice(&self.max_height.to_le_bytes());
103        offset += 4;
104
105        // Bounding sphere center (3 x f64 = 24 bytes)
106        for &v in &self.bounding_sphere_center {
107            bytes[offset..offset + 8].copy_from_slice(&v.to_le_bytes());
108            offset += 8;
109        }
110
111        // Bounding sphere radius (f64 = 8 bytes)
112        bytes[offset..offset + 8].copy_from_slice(&self.bounding_sphere_radius.to_le_bytes());
113        offset += 8;
114
115        // Horizon occlusion point (3 x f64 = 24 bytes)
116        for &v in &self.horizon_occlusion_point {
117            bytes[offset..offset + 8].copy_from_slice(&v.to_le_bytes());
118            offset += 8;
119        }
120
121        debug_assert_eq!(offset, 88);
122        bytes
123    }
124
125    /// Deserialize header from bytes.
126    pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
127        if bytes.len() < 88 {
128            return None;
129        }
130
131        let mut offset = 0;
132
133        let read_f64 = |bytes: &[u8], offset: &mut usize| -> f64 {
134            let v = f64::from_le_bytes(bytes[*offset..*offset + 8].try_into().unwrap());
135            *offset += 8;
136            v
137        };
138
139        let read_f32 = |bytes: &[u8], offset: &mut usize| -> f32 {
140            let v = f32::from_le_bytes(bytes[*offset..*offset + 4].try_into().unwrap());
141            *offset += 4;
142            v
143        };
144
145        let center = [
146            read_f64(bytes, &mut offset),
147            read_f64(bytes, &mut offset),
148            read_f64(bytes, &mut offset),
149        ];
150
151        let min_height = read_f32(bytes, &mut offset);
152        let max_height = read_f32(bytes, &mut offset);
153
154        let bounding_sphere_center = [
155            read_f64(bytes, &mut offset),
156            read_f64(bytes, &mut offset),
157            read_f64(bytes, &mut offset),
158        ];
159
160        let bounding_sphere_radius = read_f64(bytes, &mut offset);
161
162        let horizon_occlusion_point = [
163            read_f64(bytes, &mut offset),
164            read_f64(bytes, &mut offset),
165            read_f64(bytes, &mut offset),
166        ];
167
168        Some(Self {
169            center,
170            min_height,
171            max_height,
172            bounding_sphere_center,
173            bounding_sphere_radius,
174            horizon_occlusion_point,
175        })
176    }
177}
178
179/// Compute bounding sphere for a tile.
180///
181/// Returns (center, radius) in ECEF coordinates.
182///
183/// The center is the geographic center of the tile at the average height,
184/// and the radius is the maximum distance from this center to any corner.
185fn compute_bounding_sphere(
186    bounds: &TileBounds,
187    min_height: f64,
188    max_height: f64,
189) -> ([f64; 3], f64) {
190    // Use the geographic center of the tile at average height as the bounding sphere center
191    // This gives a tighter bounding sphere than using the centroid of corner points,
192    // especially for large tiles like level 0 which span half the globe.
193    let avg_height = (min_height + max_height) / 2.0;
194    let center = geodetic_to_ecef(bounds.center_lon(), bounds.center_lat(), avg_height);
195
196    // Sample corner and edge points at both height extremes
197    let points = [
198        // Corners at min height
199        geodetic_to_ecef(bounds.west, bounds.south, min_height),
200        geodetic_to_ecef(bounds.east, bounds.south, min_height),
201        geodetic_to_ecef(bounds.west, bounds.north, min_height),
202        geodetic_to_ecef(bounds.east, bounds.north, min_height),
203        // Corners at max height
204        geodetic_to_ecef(bounds.west, bounds.south, max_height),
205        geodetic_to_ecef(bounds.east, bounds.south, max_height),
206        geodetic_to_ecef(bounds.west, bounds.north, max_height),
207        geodetic_to_ecef(bounds.east, bounds.north, max_height),
208        // Edge midpoints at max height (important for large tiles)
209        geodetic_to_ecef(bounds.west, bounds.center_lat(), max_height),
210        geodetic_to_ecef(bounds.east, bounds.center_lat(), max_height),
211        geodetic_to_ecef(bounds.center_lon(), bounds.south, max_height),
212        geodetic_to_ecef(bounds.center_lon(), bounds.north, max_height),
213    ];
214
215    // Compute radius as max distance from center to any sampled point
216    let mut radius = 0.0f64;
217    for p in &points {
218        let dist = vec3_distance(&center, p);
219        radius = radius.max(dist);
220    }
221
222    (center, radius)
223}
224
225/// Compute horizon occlusion point in ellipsoid-scaled coordinates, using the
226/// Cesium `EllipsoidalOccluder` algorithm.
227///
228/// The point lies along the bounding-sphere-center direction (in scaled space).
229/// Its magnitude is the max, over every sample point on or above the tile, of
230/// the "tangent-from-horizon" magnitude that keeps that sample on the
231/// camera-side of the horizon plane. For tiles big enough that some sample is
232/// orthogonal (or beyond) the direction (e.g. a level-0 hemisphere), the
233/// magnitude diverges — matching Cesium World Terrain's huge level-0
234/// occlusion points and ensuring those tiles are never falsely culled.
235fn compute_horizon_occlusion_point(
236    bounding_sphere_center: &[f64; 3],
237    bounds: &TileBounds,
238    min_height: f64,
239    max_height: f64,
240    vertices_geodetic: &[(f64, f64, f64)],
241) -> [f64; 3] {
242    let scaled_center = ecef_to_ellipsoid_scaled(bounding_sphere_center);
243    let dir_mag = vec3_magnitude(&scaled_center);
244    if dir_mag < 1e-10 {
245        // Degenerate (sphere center near origin) — Cesium uses the +Z pole as a
246        // safe default. The visibility test will fall back to frustum culling.
247        return [0.0, 0.0, 1.0];
248    }
249    let direction = vec3_normalize(&scaled_center);
250
251    // Cesium's algorithm needs sample points that fully bracket the tile. The
252    // mesh vertices are the tightest source; fall back to corners + edge
253    // midpoints (matching what `compute_bounding_sphere` uses) when none are
254    // supplied — that keeps `from_bounds()` standalone-correct for tests.
255    let mut max_magnitude: f64 = 0.0;
256    let mut update = |position: [f64; 3]| {
257        if let Some(m) = compute_occlusion_magnitude(&position, &direction)
258            && m > max_magnitude
259        {
260            max_magnitude = m;
261        }
262    };
263
264    if vertices_geodetic.is_empty() {
265        let avg_h = (min_height + max_height) / 2.0;
266        let cx = bounds.center_lon();
267        let cy = bounds.center_lat();
268        for &h in &[min_height, max_height] {
269            update(geodetic_to_ecef(bounds.west, bounds.south, h));
270            update(geodetic_to_ecef(bounds.east, bounds.south, h));
271            update(geodetic_to_ecef(bounds.west, bounds.north, h));
272            update(geodetic_to_ecef(bounds.east, bounds.north, h));
273        }
274        update(geodetic_to_ecef(bounds.west, cy, avg_h));
275        update(geodetic_to_ecef(bounds.east, cy, avg_h));
276        update(geodetic_to_ecef(cx, bounds.south, avg_h));
277        update(geodetic_to_ecef(cx, bounds.north, avg_h));
278        update(geodetic_to_ecef(cx, cy, max_height));
279    } else {
280        for &(lon, lat, h) in vertices_geodetic {
281            update(geodetic_to_ecef(lon, lat, h));
282        }
283    }
284
285    if !max_magnitude.is_finite() || max_magnitude <= 0.0 {
286        // Couldn't compute a usable magnitude for any sample — give the
287        // tile a unit-sphere occluder so cameras over it stay visible.
288        max_magnitude = 1.0;
289    }
290
291    [
292        direction[0] * max_magnitude,
293        direction[1] * max_magnitude,
294        direction[2] * max_magnitude,
295    ]
296}
297
298/// Per-vertex magnitude from Cesium's `EllipsoidalOccluder.computeMagnitude`.
299///
300/// Returns the magnitude (along `direction`, normalized in scaled space) at
301/// which the horizon-occlusion point must sit for `position` to stay on the
302/// camera-side of the horizon plane. Returns `None` when the formula is
303/// numerically unusable (denominator <= 0) — callers should treat that the
304/// same as an infinite magnitude (the sample is "off-axis" enough that no
305/// finite occluder can shadow it).
306fn compute_occlusion_magnitude(position: &[f64; 3], direction: &[f64; 3]) -> Option<f64> {
307    let scaled = ecef_to_ellipsoid_scaled(position);
308    // Clamp magnitude to >= 1 so vertices that dip slightly below the
309    // ellipsoid (e.g. minimum-height samples near the geoid) still produce a
310    // valid candidate — Cesium's "PossiblyUnderEllipsoid" variant does this.
311    let mag_sq = (scaled[0] * scaled[0] + scaled[1] * scaled[1] + scaled[2] * scaled[2]).max(1.0);
312    let mag = mag_sq.sqrt();
313    let inv_mag = 1.0 / mag;
314    let unit = [
315        scaled[0] * inv_mag,
316        scaled[1] * inv_mag,
317        scaled[2] * inv_mag,
318    ];
319
320    let cos_alpha = unit[0] * direction[0] + unit[1] * direction[1] + unit[2] * direction[2];
321    // sin_alpha = |unit × direction|
322    let cx = unit[1] * direction[2] - unit[2] * direction[1];
323    let cy = unit[2] * direction[0] - unit[0] * direction[2];
324    let cz = unit[0] * direction[1] - unit[1] * direction[0];
325    let sin_alpha = (cx * cx + cy * cy + cz * cz).sqrt();
326    let cos_beta = inv_mag;
327    let sin_beta = (mag_sq - 1.0).max(0.0).sqrt() * inv_mag;
328
329    let denom = cos_alpha * cos_beta - sin_alpha * sin_beta;
330    if denom <= 0.0 {
331        None
332    } else {
333        Some(1.0 / denom)
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use crate::coords::WGS84_SEMI_MINOR_AXIS;
341
342    #[test]
343    fn test_header_serialization_roundtrip() {
344        let header = QuantizedMeshHeader {
345            center: [1.0, 2.0, 3.0],
346            min_height: 100.0,
347            max_height: 200.0,
348            bounding_sphere_center: [4.0, 5.0, 6.0],
349            bounding_sphere_radius: 1000.0,
350            horizon_occlusion_point: [0.1, 0.2, 0.3],
351        };
352
353        let bytes = header.to_bytes();
354        assert_eq!(bytes.len(), 88);
355
356        let parsed = QuantizedMeshHeader::from_bytes(&bytes).unwrap();
357
358        assert_eq!(header.center, parsed.center);
359        assert_eq!(header.min_height, parsed.min_height);
360        assert_eq!(header.max_height, parsed.max_height);
361        assert_eq!(header.bounding_sphere_center, parsed.bounding_sphere_center);
362        assert_eq!(header.bounding_sphere_radius, parsed.bounding_sphere_radius);
363        assert_eq!(
364            header.horizon_occlusion_point,
365            parsed.horizon_occlusion_point
366        );
367    }
368
369    #[test]
370    fn test_header_from_bounds() {
371        let bounds = TileBounds::new(-1.0, -1.0, 1.0, 1.0);
372        let header = QuantizedMeshHeader::from_bounds(&bounds, 0.0, 100.0);
373
374        // Center should be near equator/prime meridian
375        assert!(header.center[0] > 0.0); // X should be positive (facing prime meridian)
376        assert!(header.center[1].abs() < 1000.0); // Y should be near zero
377        assert!(header.center[2].abs() < 1000.0); // Z should be near zero
378
379        assert_eq!(header.min_height, 0.0);
380        assert_eq!(header.max_height, 100.0);
381        assert!(header.bounding_sphere_radius > 0.0);
382    }
383
384    #[test]
385    fn test_header_default() {
386        let header = QuantizedMeshHeader::default();
387
388        assert_eq!(header.center[0], 0.0);
389        assert_eq!(header.center[1], 0.0);
390        assert!((header.center[2] - WGS84_SEMI_MAJOR_AXIS).abs() < 1.0);
391    }
392
393    /// Regression: the level-0 eastern-hemisphere tile must produce a horizon
394    /// occlusion point that keeps cameras anywhere over the eastern hemisphere
395    /// from being culled. Pre-fix, the magnitude was ~2.4 along +Y, which
396    /// false-culled cameras with small ECEF Y (Geneva, Amsterdam, …) because
397    /// Cesium's `isScaledSpacePointVisible` test marked the tile as below
398    /// horizon. The fix uses Cesium's per-vertex algorithm; for hemisphere
399    /// tiles its formula diverges (matching Cesium World Terrain's huge level-0
400    /// occlusion magnitudes).
401    #[test]
402    fn test_horizon_occlusion_visible_for_low_longitude_cameras() {
403        use crate::coords::{WGS84_SEMI_MAJOR_AXIS as A, ecef_to_ellipsoid_scaled};
404
405        // Level-0 east tile: lon 0..180, lat -90..90.
406        let bounds = TileBounds::new(0.0, -90.0, 180.0, 90.0);
407        let header = QuantizedMeshHeader::from_bounds(&bounds, -100.0, 6000.0);
408        let p = header.horizon_occlusion_point;
409
410        // Cesium visibility test: visible iff `vtDotVc <= dt2`, or the
411        // "isOccluded" follow-up fails. For cameras over the eastern
412        // hemisphere we need `P · V > V · V` (condition 1 trivially true with
413        // negative vtDotVc).
414        let camera_ecef = geodetic_to_ecef(6.5, 46.4, 5000.0); // Geneva, 5km up
415        let v = ecef_to_ellipsoid_scaled(&camera_ecef);
416        let v_dot_v = v[0] * v[0] + v[1] * v[1] + v[2] * v[2];
417        let p_dot_v = p[0] * v[0] + p[1] * v[1] + p[2] * v[2];
418        let vt_dot_vc = v_dot_v - p_dot_v;
419        let dt2 = v_dot_v - 1.0;
420        assert!(
421            vt_dot_vc <= dt2,
422            "Geneva camera should pass condition-1 visibility for level-0 east tile; vtDotVc={vt_dot_vc} dt2={dt2}"
423        );
424
425        // Antipodal camera (mid-Pacific) must still be culled — otherwise
426        // we'd be over-rendering. Verify either condition 1 fails OR the
427        // isOccluded check succeeds.
428        let antipode_ecef = geodetic_to_ecef(-100.0, -30.0, 5000.0);
429        let v = ecef_to_ellipsoid_scaled(&antipode_ecef);
430        let v_dot_v = v[0] * v[0] + v[1] * v[1] + v[2] * v[2];
431        let p_dot_v = p[0] * v[0] + p[1] * v[1] + p[2] * v[2];
432        let vt_dot_vc = v_dot_v - p_dot_v;
433        let dt2 = v_dot_v - 1.0;
434        let visible = if vt_dot_vc <= dt2 {
435            true
436        } else {
437            let vt_minus_p = [p[0] - v[0], p[1] - v[1], p[2] - v[2]];
438            let lensq = vt_minus_p[0] * vt_minus_p[0]
439                + vt_minus_p[1] * vt_minus_p[1]
440                + vt_minus_p[2] * vt_minus_p[2];
441            (vt_dot_vc * vt_dot_vc) / lensq < dt2
442        };
443        assert!(
444            !visible,
445            "Antipodal camera over Pacific must be culled by level-0 east tile occluder"
446        );
447
448        // Reference WGS84_SEMI_MAJOR_AXIS to keep the import path honest.
449        let _ = A;
450    }
451
452    #[test]
453    fn test_bounding_sphere_at_pole() {
454        let bounds = TileBounds::new(-10.0, 80.0, 10.0, 90.0);
455        let header = QuantizedMeshHeader::from_bounds(&bounds, 0.0, 1000.0);
456
457        // Near north pole, Z should be close to semi-minor axis
458        assert!(header.center[2] > WGS84_SEMI_MINOR_AXIS * 0.9);
459        assert!(header.bounding_sphere_radius > 0.0);
460    }
461}