Skip to main content

tui_globe/
lib.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3//! Proof-of-concept terminal renderer for a 3D globe on a ratatui Braille canvas.
4//!
5//! The buffers under `tui-globe/assets/geo/` are generated by
6//! `tui-globe/tools/build_geo.py` from Natural Earth 50m cultural data
7//! (country polygons + state/province lines) and consist of vertex positions
8//! on the unit sphere plus 32-bit `LINE_STRIP` / triangle indices into them.
9//! The same format is used by the desktop Mullvad client
10//! (which is where the convention originated), so binaries
11//! taken from `mullvadvpn-app/dist-assets/geo/` would also load - but we
12//! ship our own copy so an upstream submodule bump can never break the globe.
13//!
14//! The contour buffer is a `LINE_STRIP` with `0xFFFFFFFF` primitive-restart
15//! markers separating rings. As a defensive measure for buffers that omit
16//! restarts (the desktop app's shipped buffer is one continuous strip), we
17//! also synthesize boundaries at load time by splitting any join whose 3D
18//! chord exceeds [`MAX_CHORD`].
19
20use std::{cell::RefCell, fs, io, path::Path};
21
22use ratatui::{
23    buffer::Buffer, layout::Rect, style::Color, symbols::braille::BRAILLE, widgets::Widget,
24};
25
26/// `LINE_STRIP` restart sentinel - mirrors WebGL2's `PRIMITIVE_RESTART_FIXED_INDEX`
27/// for `UNSIGNED_INT` element buffers. The shipped `land_contour_indices.gl`
28/// buffer doesn't actually contain any of these; we synthesize them at load
29/// time via [`split_long_chords`].
30const RESTART: u32 = u32::MAX;
31
32/// Maximum chord length (3D Euclidean distance on the unit sphere) between two
33/// consecutive indices before we treat the join as a strip boundary rather than
34/// a real coastline segment. Picked from the chord-length distribution: ~99.7%
35/// of genuine subdivided segments fall under 0.05, while the cross-continent
36/// stitches cluster around >= 0.1 and reach the antipodal maximum (~2.0).
37const MAX_CHORD: f32 = 0.05;
38
39/// Stroke color for coastline contours.
40pub const CONTOUR_COLOR: Color = Color::Indexed(74);
41
42/// Foreground color for land cells.
43pub const LAND_COLOR: Color = Color::Indexed(28);
44
45/// Camera/view state shared by both globe widgets.
46///
47/// `yaw` rotates around the Y axis (longitude), `pitch` around the X axis
48/// (latitude), and `zoom` scales the orthographic projection (1.0 = unit
49/// sphere fits the smaller dimension; > 1 zooms in, < 1 zooms out).
50#[derive(Debug, Clone, Copy)]
51pub struct Camera {
52    pub yaw: f32,
53    pub pitch: f32,
54    pub zoom: f32,
55}
56
57impl Default for Camera {
58    fn default() -> Self {
59        Self {
60            yaw: 0.0,
61            pitch: 0.0,
62            zoom: 1.0,
63        }
64    }
65}
66
67/// Parsed unit-sphere geometry for a globe wireframe with land fill.
68#[derive(Debug, Clone)]
69pub struct MapData {
70    /// Flat xyz floats; vertex `i` is `positions[i*3 ..= i*3 + 2]`.
71    positions: Vec<f32>,
72    /// `LINE_STRIP` indices into `positions`, with [`RESTART`] markers
73    /// synthesized via [`split_long_chords`] separating strips.
74    contour_indices: Vec<u32>,
75    /// Triangle-list indices into `positions`. Three consecutive entries
76    /// form one triangle; rasterized at cell resolution to color in the land.
77    triangle_indices: Vec<u32>,
78}
79
80impl MapData {
81    /// Load geometry from a directory containing `land_positions.gl`,
82    /// `land_contour_indices.gl`, and `land_triangle_indices.gl` (the layout
83    /// of `mullvadvpn-app/dist-assets/geo/`).
84    pub fn load(geo_dir: &Path) -> io::Result<Self> {
85        let positions = read_f32_le(&geo_dir.join("land_positions.gl"))?;
86        let raw_indices = read_u32_le(&geo_dir.join("land_contour_indices.gl"))?;
87        let contour_indices = split_long_chords(&positions, &raw_indices);
88        let triangle_indices = read_u32_le(&geo_dir.join("land_triangle_indices.gl"))?;
89        Ok(Self {
90            positions,
91            contour_indices,
92            triangle_indices,
93        })
94    }
95
96    /// Load the geometry that ships in this repository's `assets/geo/`
97    /// directory, baked into the binary at compile time. Positions are stored
98    /// as i16 (axis values quantized from [-1, 1]) and all three buffers are
99    /// zstd-compressed by `build.rs`; we decode and dequantize once here.
100    #[must_use]
101    pub fn embedded() -> Self {
102        // Keep commented for reference...
103        // upstream data
104        // const POS: &[u8] =
105        // include_bytes!("../../mullvadvpn-app/dist-assets/geo/land_positions.gl");
106        // const CIDX: &[u8] =
107        //     include_bytes!("../../mullvadvpn-app/dist-assets/geo/land_contour_indices.gl");
108        // const TIDX: &[u8] =
109        //     include_bytes!("../../mullvadvpn-app/dist-assets/geo/land_triangle_indices.gl");
110        // our data
111        // const POS: &[u8] = include_bytes!("../assets/geo/land_positions.gl");
112        // const CIDX: &[u8] = include_bytes!("../assets/geo/land_contour_indices.gl");
113        // const TIDX: &[u8] = include_bytes!("../assets/geo/land_triangle_indices.gl");
114        // let positions = bytes_to_f32_le(POS);
115        // let raw_indices = bytes_to_u32_le(CIDX);
116
117        // quantized data created by build.rs
118        const POS_QZ: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/land_positions.q16.zst"));
119        const CIDX_Z: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/land_contour_indices.zst"));
120        const TIDX_Z: &[u8] =
121            include_bytes!(concat!(env!("OUT_DIR"), "/land_triangle_indices.zst"));
122        let pos_q = zstd::decode_all(POS_QZ).expect("decode embedded positions");
123        let cidx_b = zstd::decode_all(CIDX_Z).expect("decode embedded contour indices");
124        let tidx_b = zstd::decode_all(TIDX_Z).expect("decode embedded triangle indices");
125        let positions = bytes_to_f32_from_i16_le(&pos_q);
126        let raw_indices = bytes_to_u32_le(&cidx_b);
127
128        let contour_indices = split_long_chords(&positions, &raw_indices);
129        // let triangle_indices = bytes_to_u32_le(TIDX); // If using unquantized data
130        let triangle_indices = bytes_to_u32_le(&tidx_b); // quantized data
131        Self {
132            positions,
133            contour_indices,
134            triangle_indices,
135        }
136    }
137
138    /// Number of vertices on the sphere.
139    #[must_use]
140    pub fn vertex_count(&self) -> usize {
141        self.positions.len() / 3
142    }
143
144    /// Number of `LINE_STRIP` indices including synthesized restart markers.
145    #[must_use]
146    pub fn contour_index_count(&self) -> usize {
147        self.contour_indices.len()
148    }
149
150    /// Number of land triangles (one per three indices).
151    #[must_use]
152    pub fn triangle_count(&self) -> usize {
153        self.triangle_indices.len() / 3
154    }
155}
156
157/// A globe widget driven by an externally-provided [`Camera`].
158///
159/// Renders by rasterizing land triangles and coastline contours into a shared
160/// Braille dot grid (resolution `2W x 4H` for a `W x H` cell area), then
161/// composing each cell's 8 dots into a single Braille glyph. Cells that
162/// contain any contour dot render *only* the contour bits in
163/// [`CONTOUR_COLOR`] - the land dots in those cells are dropped to preserve
164/// the thin coastline. Cells with land but no contour render the land bits
165/// in [`LAND_COLOR`].
166///
167/// Vertices on the back hemisphere (post-rotation `z < 0`) are culled and
168/// contour segments straddling the horizon are clipped to it. Triangles are
169/// culled by the sign of their average vertex z (a sphere-tessellation
170/// approximation of the surface normal direction).
171pub struct Globe<'a> {
172    data: &'a MapData,
173    camera: Camera,
174}
175
176impl<'a> Globe<'a> {
177    #[must_use]
178    pub fn new(data: &'a MapData, camera: Camera) -> Self {
179        Self { data, camera }
180    }
181}
182
183/// Dot-grid sentinels: one byte per dot. Empty means no fill, Land means inside
184/// a front-facing land triangle, and Contour means a coastline line passes
185/// through. Contour overwrites Land where they coincide.
186const DOT_EMPTY: u8 = 0;
187const DOT_LAND: u8 = 1;
188const DOT_CONTOUR: u8 = 2;
189
190/// Stipple mask AND-ed with the land dot bits in cells that don't contain any
191/// contour dots. Picked as a 2-of-8 diagonal so a fully-covered cell renders
192/// as a sparse two-dot speckle instead of `⣿` (solid), giving large continents
193/// an open-stipple look. The bit `2*row + col` ordering is row-major to match
194/// ratatui's `BRAILLE` table; the two on bits are top-right `(1, 0)` and
195/// bottom-left `(0, 3)`, which tile into a diagonal pattern across cells.
196const LAND_TEXTURE_MASK: u8 = 0b0100_0010;
197
198// Reused across frames so a 60 FPS render doesn't reallocate a (W*2)*(H*4)
199// byte buffer per tick. Thread-local because the TUI render path is
200// single-threaded and we want to avoid synchronization on the hot path.
201thread_local! {
202    static DOT_SCRATCH: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
203}
204
205impl Widget for Globe<'_> {
206    fn render(self, area: Rect, buf: &mut Buffer) {
207        if area.width == 0 || area.height == 0 {
208            return;
209        }
210        let (x_bounds, y_bounds) = canvas_bounds_for_round_globe(area, self.camera.zoom);
211        let trig = TrigCache::new(self.camera);
212
213        let aw = area.width as usize;
214        let ah = area.height as usize;
215        let dot_w = aw * 2;
216        let dot_h = ah * 4;
217
218        DOT_SCRATCH.with_borrow_mut(|dots| {
219            dots.clear();
220            dots.resize(dot_w * dot_h, DOT_EMPTY);
221
222            let xspan = x_bounds[1] - x_bounds[0];
223            let yspan = y_bounds[1] - y_bounds[0];
224            let dot_w_f = dot_w as f64;
225            let dot_h_f = dot_h as f64;
226            let world_to_dot = |x: f64, y: f64| -> (f64, f64) {
227                let dx = (x - x_bounds[0]) / xspan * dot_w_f;
228                let dy = (y_bounds[1] - y) / yspan * dot_h_f;
229                (dx, dy)
230            };
231
232            rasterize_triangles_into_dots(
233                &self.data.triangle_indices,
234                &self.data.positions,
235                &trig,
236                dots,
237                dot_w,
238                dot_h,
239                &world_to_dot,
240            );
241            rasterize_contours_into_dots(self.data, self.camera, dots, dot_w, dot_h, &world_to_dot);
242            compose_dots_into_buffer(dots, dot_w, area, buf);
243        });
244    }
245}
246
247fn rasterize_triangles_into_dots(
248    indices: &[u32],
249    positions: &[f32],
250    trig: &TrigCache,
251    dots: &mut [u8],
252    dot_w: usize,
253    dot_h: usize,
254    world_to_dot: &impl Fn(f64, f64) -> (f64, f64),
255) {
256    let max_x = (dot_w as f64) - 1.0;
257    let max_y = (dot_h as f64) - 1.0;
258    for tri in indices.chunks_exact(3) {
259        let v0 = rotate(read_vec3(positions, tri[0]), trig);
260        let v1 = rotate(read_vec3(positions, tri[1]), trig);
261        let v2 = rotate(read_vec3(positions, tri[2]), trig);
262        // Approximate backface cull: vertices on the unit sphere are roughly
263        // their own surface normals, so a negative average z means the
264        // triangle's normal points away from the camera.
265        if v0[2] + v1[2] + v2[2] < 0.0 {
266            continue;
267        }
268        let (c0x, c0y) = world_to_dot(f64::from(v0[0]), f64::from(v0[1]));
269        let (c1x, c1y) = world_to_dot(f64::from(v1[0]), f64::from(v1[1]));
270        let (c2x, c2y) = world_to_dot(f64::from(v2[0]), f64::from(v2[1]));
271        let min_x = c0x.min(c1x).min(c2x).floor().clamp(0.0, max_x) as usize;
272        let max_xi = c0x.max(c1x).max(c2x).ceil().clamp(0.0, max_x) as usize;
273        let min_y = c0y.min(c1y).min(c2y).floor().clamp(0.0, max_y) as usize;
274        let max_yi = c0y.max(c1y).max(c2y).ceil().clamp(0.0, max_y) as usize;
275        for dy in min_y..=max_yi {
276            for dx in min_x..=max_xi {
277                let px = dx as f64 + 0.5;
278                let py = dy as f64 + 0.5;
279                // Three edge functions; point is inside iff all three share a
280                // sign (no winding-convention assumption).
281                let e0 = (c1x - c0x) * (py - c0y) - (c1y - c0y) * (px - c0x);
282                let e1 = (c2x - c1x) * (py - c1y) - (c2y - c1y) * (px - c1x);
283                let e2 = (c0x - c2x) * (py - c2y) - (c0y - c2y) * (px - c2x);
284                let inside =
285                    (e0 >= 0.0 && e1 >= 0.0 && e2 >= 0.0) || (e0 <= 0.0 && e1 <= 0.0 && e2 <= 0.0);
286                if inside {
287                    dots[dy * dot_w + dx] = DOT_LAND;
288                }
289            }
290        }
291    }
292}
293
294fn rasterize_contours_into_dots(
295    data: &MapData,
296    camera: Camera,
297    dots: &mut [u8],
298    dot_w: usize,
299    dot_h: usize,
300    world_to_dot: &impl Fn(f64, f64) -> (f64, f64),
301) {
302    for [a, b] in visible_segments(data, camera) {
303        let (ax, ay) = world_to_dot(a[0], a[1]);
304        let (bx, by) = world_to_dot(b[0], b[1]);
305        draw_line_into_dots(dots, dot_w, dot_h, ax, ay, bx, by);
306    }
307}
308
309/// Bresenham line-rasterizer at integer dot coordinates. Out-of-grid samples
310/// are silently dropped.
311fn draw_line_into_dots(
312    dots: &mut [u8],
313    dot_w: usize,
314    dot_h: usize,
315    x0: f64,
316    y0: f64,
317    x1: f64,
318    y1: f64,
319) {
320    let mut x = x0.round() as i32;
321    let mut y = y0.round() as i32;
322    let xe = x1.round() as i32;
323    let ye = y1.round() as i32;
324    let dx = (xe - x).abs();
325    let dy = -(ye - y).abs();
326    let sx: i32 = if x < xe { 1 } else { -1 };
327    let sy: i32 = if y < ye { 1 } else { -1 };
328    let mut err = dx + dy;
329    loop {
330        if x >= 0 && y >= 0 && (x as usize) < dot_w && (y as usize) < dot_h {
331            dots[(y as usize) * dot_w + (x as usize)] = DOT_CONTOUR;
332        }
333        if x == xe && y == ye {
334            break;
335        }
336        let e2 = 2 * err;
337        if e2 >= dy {
338            if x == xe {
339                break;
340            }
341            err += dy;
342            x += sx;
343        }
344        if e2 <= dx {
345            if y == ye {
346                break;
347            }
348            err += dx;
349            y += sy;
350        }
351    }
352}
353
354fn compose_dots_into_buffer(dots: &[u8], dot_w: usize, area: Rect, buf: &mut Buffer) {
355    let aw = area.width as usize;
356    let ah = area.height as usize;
357    for cy in 0..ah {
358        for cx in 0..aw {
359            // Collect land and contour dots into separate bitmasks. ratatui's
360            // BRAILLE table is row-major: bit 0 is the top-left dot, bit 1 the
361            // next to the right, and so on through the four rows.
362            let mut land_bits: u8 = 0;
363            let mut contour_bits: u8 = 0;
364            for ddy in 0..4_usize {
365                for ddx in 0..2_usize {
366                    let dx = cx * 2 + ddx;
367                    let dy = cy * 4 + ddy;
368                    let bit = 1 << (ddy * 2 + ddx);
369                    match dots[dy * dot_w + dx] {
370                        DOT_LAND => land_bits |= bit,
371                        DOT_CONTOUR => contour_bits |= bit,
372                        _ => {}
373                    }
374                }
375            }
376            // If any contour dot lives in this cell, render the cell as the
377            // contour line alone - don't OR in the land dots - so coastlines
378            // keep their thin wireframe shape instead of fattening into a
379            // mostly-solid square wherever they cross land. Otherwise, AND
380            // the land dots with [`LAND_TEXTURE_MASK`] to break up flat
381            // continent interiors.
382            let (bits, color) = if contour_bits != 0 {
383                (contour_bits, CONTOUR_COLOR)
384            } else {
385                let textured = land_bits & LAND_TEXTURE_MASK;
386                if textured == 0 {
387                    continue;
388                }
389                (textured, LAND_COLOR)
390            };
391            let ch = BRAILLE[bits as usize];
392            if let Some(cell) = buf.cell_mut((area.x + cx as u16, area.y + cy as u16)) {
393                cell.set_char(ch);
394                cell.set_fg(color);
395            }
396        }
397    }
398}
399
400/// Project a (latitude, longitude) point on the globe to the cell
401/// containing it inside `area`, given the same camera the renderer
402/// will use. Returns `None` when the point is on the back hemisphere
403/// (hidden behind the globe) or projects outside `area` - callers
404/// should treat both as "don't draw an overlay".
405///
406/// Latitude / longitude are in degrees, with the same sign convention
407/// as the daemon's `GeoIpLocation`: positive latitude = north,
408/// positive longitude = east. The geometry's coordinate convention is
409/// `x = cos(φ)·sin(λ)`, `y = sin(φ)`, `z = cos(φ)·cos(λ)`, matching
410/// the rotation chain in [`Camera`].
411#[must_use]
412pub fn project_point(lat_deg: f32, lon_deg: f32, camera: Camera, area: Rect) -> Option<(u16, u16)> {
413    if area.width == 0 || area.height == 0 {
414        return None;
415    }
416    let lat = lat_deg.to_radians();
417    let lon = lon_deg.to_radians();
418    let v = [lat.cos() * lon.sin(), lat.sin(), lat.cos() * lon.cos()];
419    let trig = TrigCache::new(camera);
420    let r = rotate(v, &trig);
421    if r[2] < 0.0 {
422        // Behind the globe - caller shouldn't paint a marker through it.
423        return None;
424    }
425    let (x_bounds, y_bounds) = canvas_bounds_for_round_globe(area, camera.zoom);
426    let dot_w = f64::from(area.width) * 2.0;
427    let dot_h = f64::from(area.height) * 4.0;
428    let xspan = x_bounds[1] - x_bounds[0];
429    let yspan = y_bounds[1] - y_bounds[0];
430    let dx = (f64::from(r[0]) - x_bounds[0]) / xspan * dot_w;
431    let dy = (y_bounds[1] - f64::from(r[1])) / yspan * dot_h;
432    if dx < 0.0 || dy < 0.0 || dx >= dot_w || dy >= dot_h {
433        return None;
434    }
435    // Each cell is 2 dots wide x 4 tall; floor the dot position to the
436    // containing cell, then offset by `area`'s top-left.
437    let cell_x = area.x + (dx as u16) / 2;
438    let cell_y = area.y + (dy as u16) / 4;
439    Some((cell_x, cell_y))
440}
441
442/// Picks `x_bounds` / `y_bounds` for a `Canvas` such that a unit-radius circle
443/// inscribed at the origin renders round in the given area, accounting for
444/// terminal cells being roughly twice as tall as they are wide. Bounds are
445/// scaled by `1 / zoom` - increasing zoom shrinks the visible world rectangle,
446/// magnifying the globe.
447fn canvas_bounds_for_round_globe(area: Rect, zoom: f32) -> ([f64; 2], [f64; 2]) {
448    // The unit sphere lives in [-1, 1]; pad so the limb isn't clipped at zoom = 1.
449    const RADIUS: f64 = 1.05;
450    // Approximate ratio of a terminal cell's pixel-height to its pixel-width.
451    // 2.0 is a reasonable default across common terminals/fonts.
452    const CELL_ASPECT: f64 = 2.0;
453
454    let area_w = f64::from(area.width.max(1));
455    let area_h = f64::from(area.height.max(1));
456    let pixel_aspect = area_w / (area_h * CELL_ASPECT);
457    let scale = RADIUS / f64::from(zoom.max(1e-3));
458    if pixel_aspect >= 1.0 {
459        (
460            [-scale * pixel_aspect, scale * pixel_aspect],
461            [-scale, scale],
462        )
463    } else {
464        (
465            [-scale, scale],
466            [-scale / pixel_aspect, scale / pixel_aspect],
467        )
468    }
469}
470
471/// Yields each contour line segment, rotated to camera space, with the back
472/// hemisphere culled and segments that straddle the horizon clipped at `z = 0`.
473fn visible_segments(data: &MapData, camera: Camera) -> impl Iterator<Item = [[f64; 2]; 2]> + '_ {
474    let trig = TrigCache::new(camera);
475    data.contour_indices.windows(2).filter_map(move |w| {
476        let (i, j) = (w[0], w[1]);
477        if i == RESTART || j == RESTART {
478            return None;
479        }
480        let a = rotate(read_vec3(&data.positions, i), &trig);
481        let b = rotate(read_vec3(&data.positions, j), &trig);
482        cull_and_clip(a, b)
483    })
484}
485
486/// Precomputed sin/cos of yaw and pitch, hoisted out of the inner per-vertex loop.
487#[derive(Debug, Clone, Copy)]
488struct TrigCache {
489    sy: f32,
490    cy: f32,
491    sp: f32,
492    cp: f32,
493}
494
495impl TrigCache {
496    fn new(camera: Camera) -> Self {
497        let (sy, cy) = camera.yaw.sin_cos();
498        let (sp, cp) = camera.pitch.sin_cos();
499        Self { sy, cy, sp, cp }
500    }
501}
502
503/// Returns the visible portion of segment `a -> b` projected to 2D, after
504/// culling the back hemisphere (`z < 0`) and clipping segments that straddle
505/// the horizon plane.
506///
507/// The camera looks down the -Z axis, so a vertex is front-facing iff its
508/// post-rotation `z` is non-negative. The four cases:
509/// - both endpoints in front -> return as-is
510/// - both endpoints behind -> cull
511/// - one endpoint in front, one behind -> keep the front endpoint and clip the other to the great
512///   circle at `z = 0` (the silhouette of the globe)
513fn cull_and_clip(a: [f32; 3], b: [f32; 3]) -> Option<[[f64; 2]; 2]> {
514    let project = |v: [f32; 3]| [f64::from(v[0]), f64::from(v[1])];
515    match (a[2] >= 0.0, b[2] >= 0.0) {
516        (true, true) => Some([project(a), project(b)]),
517        (false, false) => None,
518        (visible_a, _) => {
519            let (front, back) = if visible_a { (a, b) } else { (b, a) };
520            // front[2] >= 0 and back[2] < 0, so the denominator is strictly
521            // positive and t lies in [0, 1).
522            let t = front[2] / (front[2] - back[2]);
523            let cx = front[0] + t * (back[0] - front[0]);
524            let cy = front[1] + t * (back[1] - front[1]);
525            Some([project(front), [f64::from(cx), f64::from(cy)]])
526        }
527    }
528}
529
530fn read_vec3(positions: &[f32], i: u32) -> [f32; 3] {
531    let base = (i as usize) * 3;
532    [positions[base], positions[base + 1], positions[base + 2]]
533}
534
535/// Walks a `LINE_STRIP` index buffer and inserts a [`RESTART`] marker before
536/// any index whose 3D chord to the previous index exceeds [`MAX_CHORD`].
537///
538/// The shipped contour buffer has no native restart markers - it's a single
539/// 98k-index strip, and the desktop app relies on opaque-triangle depth
540/// occlusion to hide the resulting cross-continent stitches. This function
541/// synthesizes the missing boundaries so the wireframe renders cleanly.
542fn split_long_chords(positions: &[f32], indices: &[u32]) -> Vec<u32> {
543    let max_chord_sq = MAX_CHORD * MAX_CHORD;
544    let mut out = Vec::with_capacity(indices.len() + indices.len() / 64);
545    let mut prev: Option<u32> = None;
546    for &i in indices {
547        if i == RESTART {
548            out.push(i);
549            prev = None;
550            continue;
551        }
552        if let Some(p) = prev {
553            let a = read_vec3(positions, p);
554            let b = read_vec3(positions, i);
555            let dx = a[0] - b[0];
556            let dy = a[1] - b[1];
557            let dz = a[2] - b[2];
558            if dx * dx + dy * dy + dz * dz > max_chord_sq {
559                out.push(RESTART);
560            }
561        }
562        out.push(i);
563        prev = Some(i);
564    }
565    out
566}
567
568/// Applies yaw (around Y) followed by pitch (around X) to a 3D vertex.
569///
570/// Yaw spins the globe horizontally; pitch tilts it forward/back. With both
571/// sin/cos pairs precomputed in a [`TrigCache`], this is six multiplies and
572/// two subtractions per vertex.
573fn rotate(v: [f32; 3], t: &TrigCache) -> [f32; 3] {
574    let [x, y, z] = v;
575    // Y rotation (yaw)
576    let xr = t.cy * x + t.sy * z;
577    let zr = -t.sy * x + t.cy * z;
578    // X rotation (pitch)
579    let yr2 = t.cp * y - t.sp * zr;
580    let zr2 = t.sp * y + t.cp * zr;
581    [xr, yr2, zr2]
582}
583
584/// Dequantize i16-encoded unit-sphere coordinates: each axis was multiplied
585/// by 32767 and rounded at build time; here we reverse that.
586fn bytes_to_f32_from_i16_le(b: &[u8]) -> Vec<f32> {
587    assert!(
588        b.len().is_multiple_of(2),
589        "i16 buffer length not a multiple of 2: {} bytes",
590        b.len()
591    );
592    const INV: f32 = 1.0 / 32767.0;
593    b.chunks_exact(2)
594        .map(|c| f32::from(i16::from_le_bytes([c[0], c[1]])) * INV)
595        .collect()
596}
597
598fn bytes_to_f32_le(b: &[u8]) -> Vec<f32> {
599    assert!(
600        b.len().is_multiple_of(4),
601        "f32 buffer length not a multiple of 4: {} bytes",
602        b.len()
603    );
604    b.chunks_exact(4)
605        .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
606        .collect()
607}
608
609fn bytes_to_u32_le(b: &[u8]) -> Vec<u32> {
610    assert!(
611        b.len().is_multiple_of(4),
612        "u32 buffer length not a multiple of 4: {} bytes",
613        b.len()
614    );
615    b.chunks_exact(4)
616        .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]]))
617        .collect()
618}
619
620fn read_f32_le(path: &Path) -> io::Result<Vec<f32>> {
621    Ok(bytes_to_f32_le(&fs::read(path)?))
622}
623
624fn read_u32_le(path: &Path) -> io::Result<Vec<u32>> {
625    Ok(bytes_to_u32_le(&fs::read(path)?))
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631
632    #[test]
633    fn embedded_data_parses_to_a_thin_sphere_shell() {
634        let map = MapData::embedded();
635        assert!(map.vertex_count() > 1000, "expected many vertices");
636        assert!(map.contour_index_count() > 1000, "expected many indices");
637        // The contour data isn't strictly on a unit sphere - some subdivided
638        // segments sit at radius ~0.99 to render slightly above the land
639        // triangle layer (which the desktop app scales to 0.99999). Just
640        // assert all vertices lie in a thin shell near the unit sphere.
641        for chunk in map.positions.chunks_exact(3) {
642            let r2 = chunk[0] * chunk[0] + chunk[1] * chunk[1] + chunk[2] * chunk[2];
643            assert!(
644                (0.95..=1.05).contains(&r2),
645                "vertex outside expected shell: r^2 = {r2}"
646            );
647        }
648    }
649
650    #[test]
651    fn rotate_preserves_length() {
652        let camera = Camera {
653            yaw: 1.234,
654            pitch: -0.5,
655            zoom: 1.0,
656        };
657        let trig = TrigCache::new(camera);
658        let v = [0.6_f32, 0.5, -0.624_499_8];
659        let r = rotate(v, &trig);
660        let len2 = |a: [f32; 3]| a[0] * a[0] + a[1] * a[1] + a[2] * a[2];
661        assert!((len2(v) - len2(r)).abs() < 1e-5);
662    }
663
664    #[test]
665    fn rotate_with_zero_pitch_matches_yaw_only() {
666        // With pitch = 0, the new function should agree with a pure Y rotation.
667        let camera = Camera {
668            yaw: 0.7,
669            pitch: 0.0,
670            zoom: 1.0,
671        };
672        let trig = TrigCache::new(camera);
673        let v = [1.0_f32, 0.0, 0.0];
674        let r = rotate(v, &trig);
675        let (sy, cy) = 0.7_f32.sin_cos();
676        let expected = [cy, 0.0, -sy];
677        for i in 0..3 {
678            assert!((r[i] - expected[i]).abs() < 1e-6);
679        }
680    }
681
682    #[test]
683    fn cull_keeps_segments_fully_in_front() {
684        let r = cull_and_clip([0.5, 0.0, 0.5], [-0.5, 0.0, 0.5]).unwrap();
685        assert!((r[0][0] - 0.5).abs() < 1e-6);
686        assert!((r[1][0] - (-0.5)).abs() < 1e-6);
687    }
688
689    #[test]
690    fn cull_drops_segments_fully_behind() {
691        assert!(cull_and_clip([0.5, 0.0, -0.5], [-0.5, 0.0, -0.5]).is_none());
692    }
693
694    #[test]
695    fn cull_clips_segments_at_the_horizon() {
696        // Front endpoint at x=0,z=+0.5; back at x=1,z=-0.5.
697        // The horizon crossing is halfway: t = 0.5/(0.5 - -0.5) = 0.5,
698        // so x at the clip is 0.0 + 0.5*(1.0 - 0.0) = 0.5.
699        let r = cull_and_clip([0.0, 0.0, 0.5], [1.0, 0.0, -0.5]).unwrap();
700        assert!((r[0][0] - 0.0).abs() < 1e-6, "front endpoint preserved");
701        assert!(
702            (r[1][0] - 0.5).abs() < 1e-6,
703            "back endpoint clipped to horizon"
704        );
705    }
706
707    #[test]
708    fn split_long_chords_inserts_restart_markers_in_the_shipped_data() {
709        // The raw shipped buffer has zero restart markers, so any non-zero
710        // count proves the synthesis ran and acted on real data.
711        let map = MapData::embedded();
712        let restarts = map
713            .contour_indices
714            .iter()
715            .filter(|&&i| i == RESTART)
716            .count();
717        assert!(
718            restarts > 100,
719            "expected many synthesized restarts, got {restarts}"
720        );
721    }
722
723    #[test]
724    fn no_segment_in_the_shipped_data_exceeds_the_chord_threshold() {
725        let map = MapData::embedded();
726        let mut max_sq = 0.0_f32;
727        for w in map.contour_indices.windows(2) {
728            if w[0] == RESTART || w[1] == RESTART {
729                continue;
730            }
731            let a = read_vec3(&map.positions, w[0]);
732            let b = read_vec3(&map.positions, w[1]);
733            let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
734            let d2 = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
735            if d2 > max_sq {
736                max_sq = d2;
737            }
738        }
739        assert!(
740            max_sq <= MAX_CHORD * MAX_CHORD,
741            "longest surviving chord {} exceeds threshold {MAX_CHORD}",
742            max_sq.sqrt()
743        );
744    }
745
746    #[test]
747    fn cull_handles_either_endpoint_being_the_back_one() {
748        // Same geometry, swapped argument order - should produce the same segment.
749        let forward = cull_and_clip([0.0, 0.0, 0.5], [1.0, 0.0, -0.5]).unwrap();
750        let reverse = cull_and_clip([1.0, 0.0, -0.5], [0.0, 0.0, 0.5]).unwrap();
751        // The "front-first" element should always be the visible vertex.
752        assert!((forward[0][0] - reverse[0][0]).abs() < 1e-6);
753        assert!((forward[1][0] - reverse[1][0]).abs() < 1e-6);
754    }
755}