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 —
46    /// callers that have the actual mesh vertices should prefer
47    /// [`from_bounds_with_vertices_iter`](Self::from_bounds_with_vertices_iter)
48    /// for tighter occlusion.
49    pub fn from_bounds(bounds: &TileBounds, min_height: f32, max_height: f32) -> Self {
50        Self::from_bounds_with_vertices_iter(
51            bounds,
52            min_height,
53            max_height,
54            std::iter::empty::<[f64; 3]>(),
55        )
56    }
57
58    /// Like [`from_bounds`](Self::from_bounds), but uses the supplied mesh
59    /// vertices (geodetic `[lon, lat, height]`) to compute a tighter horizon
60    /// occlusion point via the Cesium `EllipsoidalOccluder` algorithm.
61    ///
62    /// Accepts any `IntoIterator<Item = [f64; 3]>`, so callers can stream
63    /// directly from a flat `&[f32]` buffer or any other source without
64    /// allocating an intermediate `Vec`. To feed an `f32` mesh, adapt with
65    /// `chunks_exact(3).map(|c| [c[0] as f64, c[1] as f64, c[2] as f64])`.
66    ///
67    /// Internal math runs in `f64` regardless of input precision — ECEF
68    /// coordinates at Earth scale need `f64` to keep sub-metre accuracy.
69    pub fn from_bounds_with_vertices_iter<I>(
70        bounds: &TileBounds,
71        min_height: f32,
72        max_height: f32,
73        vertices_geodetic: I,
74    ) -> Self
75    where
76        I: IntoIterator<Item = [f64; 3]>,
77    {
78        let center_lon = bounds.center_lon();
79        let center_lat = bounds.center_lat();
80        let center_height = (min_height as f64 + max_height as f64) / 2.0;
81
82        let center = geodetic_to_ecef(center_lon, center_lat, center_height);
83
84        let (bounding_sphere_center, bounding_sphere_radius) =
85            compute_bounding_sphere(bounds, min_height as f64, max_height as f64);
86
87        let horizon_occlusion_point = compute_horizon_occlusion_point(
88            &bounding_sphere_center,
89            bounds,
90            min_height as f64,
91            max_height as f64,
92            vertices_geodetic,
93        );
94
95        Self {
96            center,
97            min_height,
98            max_height,
99            bounding_sphere_center,
100            bounding_sphere_radius,
101            horizon_occlusion_point,
102        }
103    }
104
105    /// Slice-based wrapper around
106    /// [`from_bounds_with_vertices_iter`](Self::from_bounds_with_vertices_iter).
107    ///
108    /// Retained for backward compatibility; prefer the iterator form when
109    /// your vertex data is in a layout (`Vec<f32>`, GPU buffer, ...) that
110    /// would otherwise require allocating an intermediate `Vec<(f64, f64, f64)>`.
111    #[deprecated(
112        since = "0.2.1",
113        note = "Use `from_bounds_with_vertices_iter` to avoid allocating an intermediate `Vec<(f64, f64, f64)>`"
114    )]
115    pub fn from_bounds_with_vertices(
116        bounds: &TileBounds,
117        min_height: f32,
118        max_height: f32,
119        vertices_geodetic: &[(f64, f64, f64)],
120    ) -> Self {
121        Self::from_bounds_with_vertices_iter(
122            bounds,
123            min_height,
124            max_height,
125            vertices_geodetic.iter().map(|&(a, b, c)| [a, b, c]),
126        )
127    }
128
129    /// Serialize header to bytes (88 bytes, little-endian).
130    pub fn to_bytes(&self) -> [u8; 88] {
131        let mut bytes = [0u8; 88];
132        let mut offset = 0;
133
134        // Center (3 x f64 = 24 bytes)
135        for &v in &self.center {
136            bytes[offset..offset + 8].copy_from_slice(&v.to_le_bytes());
137            offset += 8;
138        }
139
140        // Min/Max height (2 x f32 = 8 bytes)
141        bytes[offset..offset + 4].copy_from_slice(&self.min_height.to_le_bytes());
142        offset += 4;
143        bytes[offset..offset + 4].copy_from_slice(&self.max_height.to_le_bytes());
144        offset += 4;
145
146        // Bounding sphere center (3 x f64 = 24 bytes)
147        for &v in &self.bounding_sphere_center {
148            bytes[offset..offset + 8].copy_from_slice(&v.to_le_bytes());
149            offset += 8;
150        }
151
152        // Bounding sphere radius (f64 = 8 bytes)
153        bytes[offset..offset + 8].copy_from_slice(&self.bounding_sphere_radius.to_le_bytes());
154        offset += 8;
155
156        // Horizon occlusion point (3 x f64 = 24 bytes)
157        for &v in &self.horizon_occlusion_point {
158            bytes[offset..offset + 8].copy_from_slice(&v.to_le_bytes());
159            offset += 8;
160        }
161
162        debug_assert_eq!(offset, 88);
163        bytes
164    }
165
166    /// Deserialize header from bytes.
167    pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
168        if bytes.len() < 88 {
169            return None;
170        }
171
172        let mut offset = 0;
173
174        let read_f64 = |bytes: &[u8], offset: &mut usize| -> f64 {
175            let v = f64::from_le_bytes(bytes[*offset..*offset + 8].try_into().unwrap());
176            *offset += 8;
177            v
178        };
179
180        let read_f32 = |bytes: &[u8], offset: &mut usize| -> f32 {
181            let v = f32::from_le_bytes(bytes[*offset..*offset + 4].try_into().unwrap());
182            *offset += 4;
183            v
184        };
185
186        let center = [
187            read_f64(bytes, &mut offset),
188            read_f64(bytes, &mut offset),
189            read_f64(bytes, &mut offset),
190        ];
191
192        let min_height = read_f32(bytes, &mut offset);
193        let max_height = read_f32(bytes, &mut offset);
194
195        let bounding_sphere_center = [
196            read_f64(bytes, &mut offset),
197            read_f64(bytes, &mut offset),
198            read_f64(bytes, &mut offset),
199        ];
200
201        let bounding_sphere_radius = read_f64(bytes, &mut offset);
202
203        let horizon_occlusion_point = [
204            read_f64(bytes, &mut offset),
205            read_f64(bytes, &mut offset),
206            read_f64(bytes, &mut offset),
207        ];
208
209        Some(Self {
210            center,
211            min_height,
212            max_height,
213            bounding_sphere_center,
214            bounding_sphere_radius,
215            horizon_occlusion_point,
216        })
217    }
218}
219
220/// Compute bounding sphere for a tile.
221///
222/// Returns (center, radius) in ECEF coordinates.
223///
224/// The center is the geographic center of the tile at the average height,
225/// and the radius is the maximum distance from this center to any corner.
226fn compute_bounding_sphere(
227    bounds: &TileBounds,
228    min_height: f64,
229    max_height: f64,
230) -> ([f64; 3], f64) {
231    // Use the geographic center of the tile at average height as the bounding sphere center
232    // This gives a tighter bounding sphere than using the centroid of corner points,
233    // especially for large tiles like level 0 which span half the globe.
234    let avg_height = (min_height + max_height) / 2.0;
235    let center = geodetic_to_ecef(bounds.center_lon(), bounds.center_lat(), avg_height);
236
237    // Sample corner and edge points at both height extremes
238    let points = [
239        // Corners at min height
240        geodetic_to_ecef(bounds.west, bounds.south, min_height),
241        geodetic_to_ecef(bounds.east, bounds.south, min_height),
242        geodetic_to_ecef(bounds.west, bounds.north, min_height),
243        geodetic_to_ecef(bounds.east, bounds.north, min_height),
244        // Corners at max height
245        geodetic_to_ecef(bounds.west, bounds.south, max_height),
246        geodetic_to_ecef(bounds.east, bounds.south, max_height),
247        geodetic_to_ecef(bounds.west, bounds.north, max_height),
248        geodetic_to_ecef(bounds.east, bounds.north, max_height),
249        // Edge midpoints at max height (important for large tiles)
250        geodetic_to_ecef(bounds.west, bounds.center_lat(), max_height),
251        geodetic_to_ecef(bounds.east, bounds.center_lat(), max_height),
252        geodetic_to_ecef(bounds.center_lon(), bounds.south, max_height),
253        geodetic_to_ecef(bounds.center_lon(), bounds.north, max_height),
254    ];
255
256    // Compute radius as max distance from center to any sampled point
257    let mut radius = 0.0f64;
258    for p in &points {
259        let dist = vec3_distance(&center, p);
260        radius = radius.max(dist);
261    }
262
263    (center, radius)
264}
265
266/// Compute horizon occlusion point in ellipsoid-scaled coordinates, using the
267/// Cesium `EllipsoidalOccluder` algorithm.
268///
269/// The point lies along the bounding-sphere-center direction (in scaled space).
270/// Its magnitude is the max, over every sample point on or above the tile, of
271/// the "tangent-from-horizon" magnitude that keeps that sample on the
272/// camera-side of the horizon plane. For tiles big enough that some sample is
273/// orthogonal (or beyond) the direction (e.g. a level-0 hemisphere), the
274/// magnitude diverges — matching Cesium World Terrain's huge level-0
275/// occlusion points and ensuring those tiles are never falsely culled.
276fn compute_horizon_occlusion_point<I>(
277    bounding_sphere_center: &[f64; 3],
278    bounds: &TileBounds,
279    min_height: f64,
280    max_height: f64,
281    vertices_geodetic: I,
282) -> [f64; 3]
283where
284    I: IntoIterator<Item = [f64; 3]>,
285{
286    let scaled_center = ecef_to_ellipsoid_scaled(bounding_sphere_center);
287    let dir_mag = vec3_magnitude(&scaled_center);
288    if dir_mag < 1e-10 {
289        // Degenerate (sphere center near origin) — Cesium uses the +Z pole as a
290        // safe default. The visibility test will fall back to frustum culling.
291        return [0.0, 0.0, 1.0];
292    }
293    let direction = vec3_normalize(&scaled_center);
294
295    // Cesium's algorithm needs sample points that fully bracket the tile. The
296    // mesh vertices are the tightest source; fall back to corners + edge
297    // midpoints (matching what `compute_bounding_sphere` uses) when none are
298    // supplied — that keeps `from_bounds()` standalone-correct for tests.
299    let mut max_magnitude: f64 = 0.0;
300    let mut update = |position: [f64; 3]| {
301        if let Some(m) = compute_occlusion_magnitude(&position, &direction)
302            && m > max_magnitude
303        {
304            max_magnitude = m;
305        }
306    };
307
308    let mut iter = vertices_geodetic.into_iter().peekable();
309    if iter.peek().is_none() {
310        let avg_h = (min_height + max_height) / 2.0;
311        let cx = bounds.center_lon();
312        let cy = bounds.center_lat();
313        for &h in &[min_height, max_height] {
314            update(geodetic_to_ecef(bounds.west, bounds.south, h));
315            update(geodetic_to_ecef(bounds.east, bounds.south, h));
316            update(geodetic_to_ecef(bounds.west, bounds.north, h));
317            update(geodetic_to_ecef(bounds.east, bounds.north, h));
318        }
319        update(geodetic_to_ecef(bounds.west, cy, avg_h));
320        update(geodetic_to_ecef(bounds.east, cy, avg_h));
321        update(geodetic_to_ecef(cx, bounds.south, avg_h));
322        update(geodetic_to_ecef(cx, bounds.north, avg_h));
323        update(geodetic_to_ecef(cx, cy, max_height));
324    } else {
325        for [lon, lat, h] in iter {
326            update(geodetic_to_ecef(lon, lat, h));
327        }
328    }
329
330    if !max_magnitude.is_finite() || max_magnitude <= 0.0 {
331        // Couldn't compute a usable magnitude for any sample — give the
332        // tile a unit-sphere occluder so cameras over it stay visible.
333        max_magnitude = 1.0;
334    }
335
336    [
337        direction[0] * max_magnitude,
338        direction[1] * max_magnitude,
339        direction[2] * max_magnitude,
340    ]
341}
342
343/// Per-vertex magnitude from Cesium's `EllipsoidalOccluder.computeMagnitude`.
344///
345/// Returns the magnitude (along `direction`, normalized in scaled space) at
346/// which the horizon-occlusion point must sit for `position` to stay on the
347/// camera-side of the horizon plane. Returns `None` when the formula is
348/// numerically unusable (denominator <= 0) — callers should treat that the
349/// same as an infinite magnitude (the sample is "off-axis" enough that no
350/// finite occluder can shadow it).
351fn compute_occlusion_magnitude(position: &[f64; 3], direction: &[f64; 3]) -> Option<f64> {
352    let scaled = ecef_to_ellipsoid_scaled(position);
353    // Clamp magnitude to >= 1 so vertices that dip slightly below the
354    // ellipsoid (e.g. minimum-height samples near the geoid) still produce a
355    // valid candidate — Cesium's "PossiblyUnderEllipsoid" variant does this.
356    let mag_sq = (scaled[0] * scaled[0] + scaled[1] * scaled[1] + scaled[2] * scaled[2]).max(1.0);
357    let mag = mag_sq.sqrt();
358    let inv_mag = 1.0 / mag;
359    let unit = [
360        scaled[0] * inv_mag,
361        scaled[1] * inv_mag,
362        scaled[2] * inv_mag,
363    ];
364
365    let cos_alpha = unit[0] * direction[0] + unit[1] * direction[1] + unit[2] * direction[2];
366    // sin_alpha = |unit × direction|
367    let cx = unit[1] * direction[2] - unit[2] * direction[1];
368    let cy = unit[2] * direction[0] - unit[0] * direction[2];
369    let cz = unit[0] * direction[1] - unit[1] * direction[0];
370    let sin_alpha = (cx * cx + cy * cy + cz * cz).sqrt();
371    let cos_beta = inv_mag;
372    let sin_beta = (mag_sq - 1.0).max(0.0).sqrt() * inv_mag;
373
374    let denom = cos_alpha * cos_beta - sin_alpha * sin_beta;
375    if denom <= 0.0 {
376        None
377    } else {
378        Some(1.0 / denom)
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use crate::coords::WGS84_SEMI_MINOR_AXIS;
386
387    /// Both APIs must agree bit-for-bit. The iter form is the canonical
388    /// implementation now; the slice form is just a thin adapter.
389    #[test]
390    #[allow(deprecated)]
391    fn from_bounds_with_vertices_iter_matches_slice_form() {
392        let bounds = TileBounds::new(-1.0, -1.0, 1.0, 1.0);
393        let verts_tuples = vec![
394            (-0.5_f64, -0.5, 10.0),
395            (0.5, -0.5, 20.0),
396            (-0.5, 0.5, 30.0),
397            (0.5, 0.5, 40.0),
398        ];
399        let from_slice =
400            QuantizedMeshHeader::from_bounds_with_vertices(&bounds, 0.0, 100.0, &verts_tuples);
401
402        // Streaming form — no intermediate Vec.
403        let iter = verts_tuples.iter().map(|&(a, b, c)| [a, b, c]);
404        let from_iter =
405            QuantizedMeshHeader::from_bounds_with_vertices_iter(&bounds, 0.0, 100.0, iter);
406
407        assert_eq!(
408            from_slice.horizon_occlusion_point,
409            from_iter.horizon_occlusion_point
410        );
411        assert_eq!(
412            from_slice.bounding_sphere_center,
413            from_iter.bounding_sphere_center
414        );
415        assert_eq!(
416            from_slice.bounding_sphere_radius,
417            from_iter.bounding_sphere_radius
418        );
419    }
420
421    /// Mimics a martini-style flat `Vec<f32>` (already mapped to geodetic
422    /// `[lon, lat, height]`) being streamed in without any intermediate
423    /// `Vec<(f64, f64, f64)>` allocation.
424    #[test]
425    fn from_bounds_with_vertices_iter_accepts_flat_f32_stream() {
426        let bounds = TileBounds::new(-1.0, -1.0, 1.0, 1.0);
427        let flat_f32: Vec<f32> = vec![
428            -0.5, -0.5, 10.0, // vertex 0
429            0.5, -0.5, 20.0, // vertex 1
430            -0.5, 0.5, 30.0, // vertex 2
431            0.5, 0.5, 40.0, // vertex 3
432        ];
433
434        let iter = flat_f32
435            .chunks_exact(3)
436            .map(|c| [c[0] as f64, c[1] as f64, c[2] as f64]);
437
438        let header = QuantizedMeshHeader::from_bounds_with_vertices_iter(&bounds, 0.0, 100.0, iter);
439
440        // Sanity: occlusion point is non-degenerate.
441        let p = header.horizon_occlusion_point;
442        assert!(p.iter().all(|v| v.is_finite()));
443        assert!(p[0].abs() + p[1].abs() + p[2].abs() > 0.0);
444    }
445
446    #[test]
447    fn test_header_serialization_roundtrip() {
448        let header = QuantizedMeshHeader {
449            center: [1.0, 2.0, 3.0],
450            min_height: 100.0,
451            max_height: 200.0,
452            bounding_sphere_center: [4.0, 5.0, 6.0],
453            bounding_sphere_radius: 1000.0,
454            horizon_occlusion_point: [0.1, 0.2, 0.3],
455        };
456
457        let bytes = header.to_bytes();
458        assert_eq!(bytes.len(), 88);
459
460        let parsed = QuantizedMeshHeader::from_bytes(&bytes).unwrap();
461
462        assert_eq!(header.center, parsed.center);
463        assert_eq!(header.min_height, parsed.min_height);
464        assert_eq!(header.max_height, parsed.max_height);
465        assert_eq!(header.bounding_sphere_center, parsed.bounding_sphere_center);
466        assert_eq!(header.bounding_sphere_radius, parsed.bounding_sphere_radius);
467        assert_eq!(
468            header.horizon_occlusion_point,
469            parsed.horizon_occlusion_point
470        );
471    }
472
473    #[test]
474    fn test_header_from_bounds() {
475        let bounds = TileBounds::new(-1.0, -1.0, 1.0, 1.0);
476        let header = QuantizedMeshHeader::from_bounds(&bounds, 0.0, 100.0);
477
478        // Center should be near equator/prime meridian
479        assert!(header.center[0] > 0.0); // X should be positive (facing prime meridian)
480        assert!(header.center[1].abs() < 1000.0); // Y should be near zero
481        assert!(header.center[2].abs() < 1000.0); // Z should be near zero
482
483        assert_eq!(header.min_height, 0.0);
484        assert_eq!(header.max_height, 100.0);
485        assert!(header.bounding_sphere_radius > 0.0);
486    }
487
488    #[test]
489    fn test_header_default() {
490        let header = QuantizedMeshHeader::default();
491
492        assert_eq!(header.center[0], 0.0);
493        assert_eq!(header.center[1], 0.0);
494        assert!((header.center[2] - WGS84_SEMI_MAJOR_AXIS).abs() < 1.0);
495    }
496
497    /// Regression: the level-0 eastern-hemisphere tile must produce a horizon
498    /// occlusion point that keeps cameras anywhere over the eastern hemisphere
499    /// from being culled. Pre-fix, the magnitude was ~2.4 along +Y, which
500    /// false-culled cameras with small ECEF Y (Geneva, Amsterdam, …) because
501    /// Cesium's `isScaledSpacePointVisible` test marked the tile as below
502    /// horizon. The fix uses Cesium's per-vertex algorithm; for hemisphere
503    /// tiles its formula diverges (matching Cesium World Terrain's huge level-0
504    /// occlusion magnitudes).
505    #[test]
506    fn test_horizon_occlusion_visible_for_low_longitude_cameras() {
507        use crate::coords::{WGS84_SEMI_MAJOR_AXIS as A, ecef_to_ellipsoid_scaled};
508
509        // Level-0 east tile: lon 0..180, lat -90..90.
510        let bounds = TileBounds::new(0.0, -90.0, 180.0, 90.0);
511        let header = QuantizedMeshHeader::from_bounds(&bounds, -100.0, 6000.0);
512        let p = header.horizon_occlusion_point;
513
514        // Cesium visibility test: visible iff `vtDotVc <= dt2`, or the
515        // "isOccluded" follow-up fails. For cameras over the eastern
516        // hemisphere we need `P · V > V · V` (condition 1 trivially true with
517        // negative vtDotVc).
518        let camera_ecef = geodetic_to_ecef(6.5, 46.4, 5000.0); // Geneva, 5km up
519        let v = ecef_to_ellipsoid_scaled(&camera_ecef);
520        let v_dot_v = v[0] * v[0] + v[1] * v[1] + v[2] * v[2];
521        let p_dot_v = p[0] * v[0] + p[1] * v[1] + p[2] * v[2];
522        let vt_dot_vc = v_dot_v - p_dot_v;
523        let dt2 = v_dot_v - 1.0;
524        assert!(
525            vt_dot_vc <= dt2,
526            "Geneva camera should pass condition-1 visibility for level-0 east tile; vtDotVc={vt_dot_vc} dt2={dt2}"
527        );
528
529        // Antipodal camera (mid-Pacific) must still be culled — otherwise
530        // we'd be over-rendering. Verify either condition 1 fails OR the
531        // isOccluded check succeeds.
532        let antipode_ecef = geodetic_to_ecef(-100.0, -30.0, 5000.0);
533        let v = ecef_to_ellipsoid_scaled(&antipode_ecef);
534        let v_dot_v = v[0] * v[0] + v[1] * v[1] + v[2] * v[2];
535        let p_dot_v = p[0] * v[0] + p[1] * v[1] + p[2] * v[2];
536        let vt_dot_vc = v_dot_v - p_dot_v;
537        let dt2 = v_dot_v - 1.0;
538        let visible = if vt_dot_vc <= dt2 {
539            true
540        } else {
541            let vt_minus_p = [p[0] - v[0], p[1] - v[1], p[2] - v[2]];
542            let lensq = vt_minus_p[0] * vt_minus_p[0]
543                + vt_minus_p[1] * vt_minus_p[1]
544                + vt_minus_p[2] * vt_minus_p[2];
545            (vt_dot_vc * vt_dot_vc) / lensq < dt2
546        };
547        assert!(
548            !visible,
549            "Antipodal camera over Pacific must be culled by level-0 east tile occluder"
550        );
551
552        // Reference WGS84_SEMI_MAJOR_AXIS to keep the import path honest.
553        let _ = A;
554    }
555
556    #[test]
557    fn test_bounding_sphere_at_pole() {
558        let bounds = TileBounds::new(-10.0, 80.0, 10.0, 90.0);
559        let header = QuantizedMeshHeader::from_bounds(&bounds, 0.0, 1000.0);
560
561        // Near north pole, Z should be close to semi-minor axis
562        assert!(header.center[2] > WGS84_SEMI_MINOR_AXIS * 0.9);
563        assert!(header.bounding_sphere_radius > 0.0);
564    }
565}