tui-globe 0.0.0

Proof-of-concept terminal renderer for the Mullvad 3D globe data
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
// SPDX-License-Identifier: GPL-3.0-or-later

//! Proof-of-concept terminal renderer for a 3D globe on a ratatui Braille canvas.
//!
//! The buffers under `tui-globe/assets/geo/` are generated by
//! `tui-globe/tools/build_geo.py` from Natural Earth 50m cultural data
//! (country polygons + state/province lines) and consist of vertex positions
//! on the unit sphere plus 32-bit `LINE_STRIP` / triangle indices into them.
//! The same format is used by the desktop Mullvad client
//! (which is where the convention originated), so binaries
//! taken from `mullvadvpn-app/dist-assets/geo/` would also load - but we
//! ship our own copy so an upstream submodule bump can never break the globe.
//!
//! The contour buffer is a `LINE_STRIP` with `0xFFFFFFFF` primitive-restart
//! markers separating rings. As a defensive measure for buffers that omit
//! restarts (the desktop app's shipped buffer is one continuous strip), we
//! also synthesize boundaries at load time by splitting any join whose 3D
//! chord exceeds [`MAX_CHORD`].

use std::{cell::RefCell, fs, io, path::Path};

use ratatui::{
    buffer::Buffer, layout::Rect, style::Color, symbols::braille::BRAILLE, widgets::Widget,
};

/// `LINE_STRIP` restart sentinel - mirrors WebGL2's `PRIMITIVE_RESTART_FIXED_INDEX`
/// for `UNSIGNED_INT` element buffers. The shipped `land_contour_indices.gl`
/// buffer doesn't actually contain any of these; we synthesize them at load
/// time via [`split_long_chords`].
const RESTART: u32 = u32::MAX;

/// Maximum chord length (3D Euclidean distance on the unit sphere) between two
/// consecutive indices before we treat the join as a strip boundary rather than
/// a real coastline segment. Picked from the chord-length distribution: ~99.7%
/// of genuine subdivided segments fall under 0.05, while the cross-continent
/// stitches cluster around >= 0.1 and reach the antipodal maximum (~2.0).
const MAX_CHORD: f32 = 0.05;

/// Stroke color for coastline contours.
pub const CONTOUR_COLOR: Color = Color::Indexed(74);

/// Foreground color for land cells.
pub const LAND_COLOR: Color = Color::Indexed(28);

/// Camera/view state shared by both globe widgets.
///
/// `yaw` rotates around the Y axis (longitude), `pitch` around the X axis
/// (latitude), and `zoom` scales the orthographic projection (1.0 = unit
/// sphere fits the smaller dimension; > 1 zooms in, < 1 zooms out).
#[derive(Debug, Clone, Copy)]
pub struct Camera {
    pub yaw: f32,
    pub pitch: f32,
    pub zoom: f32,
}

impl Default for Camera {
    fn default() -> Self {
        Self {
            yaw: 0.0,
            pitch: 0.0,
            zoom: 1.0,
        }
    }
}

/// Parsed unit-sphere geometry for a globe wireframe with land fill.
#[derive(Debug, Clone)]
pub struct MapData {
    /// Flat xyz floats; vertex `i` is `positions[i*3 ..= i*3 + 2]`.
    positions: Vec<f32>,
    /// `LINE_STRIP` indices into `positions`, with [`RESTART`] markers
    /// synthesized via [`split_long_chords`] separating strips.
    contour_indices: Vec<u32>,
    /// Triangle-list indices into `positions`. Three consecutive entries
    /// form one triangle; rasterized at cell resolution to color in the land.
    triangle_indices: Vec<u32>,
}

impl MapData {
    /// Load geometry from a directory containing `land_positions.gl`,
    /// `land_contour_indices.gl`, and `land_triangle_indices.gl` (the layout
    /// of `mullvadvpn-app/dist-assets/geo/`).
    pub fn load(geo_dir: &Path) -> io::Result<Self> {
        let positions = read_f32_le(&geo_dir.join("land_positions.gl"))?;
        let raw_indices = read_u32_le(&geo_dir.join("land_contour_indices.gl"))?;
        let contour_indices = split_long_chords(&positions, &raw_indices);
        let triangle_indices = read_u32_le(&geo_dir.join("land_triangle_indices.gl"))?;
        Ok(Self {
            positions,
            contour_indices,
            triangle_indices,
        })
    }

    /// Load the geometry that ships in this repository's `assets/geo/`
    /// directory, baked into the binary at compile time. Positions are stored
    /// as i16 (axis values quantized from [-1, 1]) and all three buffers are
    /// zstd-compressed by `build.rs`; we decode and dequantize once here.
    #[must_use]
    pub fn embedded() -> Self {
        // Keep commented for reference...
        // upstream data
        // const POS: &[u8] =
        // include_bytes!("../../mullvadvpn-app/dist-assets/geo/land_positions.gl");
        // const CIDX: &[u8] =
        //     include_bytes!("../../mullvadvpn-app/dist-assets/geo/land_contour_indices.gl");
        // const TIDX: &[u8] =
        //     include_bytes!("../../mullvadvpn-app/dist-assets/geo/land_triangle_indices.gl");
        // our data
        // const POS: &[u8] = include_bytes!("../assets/geo/land_positions.gl");
        // const CIDX: &[u8] = include_bytes!("../assets/geo/land_contour_indices.gl");
        // const TIDX: &[u8] = include_bytes!("../assets/geo/land_triangle_indices.gl");
        // let positions = bytes_to_f32_le(POS);
        // let raw_indices = bytes_to_u32_le(CIDX);

        // quantized data created by build.rs
        const POS_QZ: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/land_positions.q16.zst"));
        const CIDX_Z: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/land_contour_indices.zst"));
        const TIDX_Z: &[u8] =
            include_bytes!(concat!(env!("OUT_DIR"), "/land_triangle_indices.zst"));
        let pos_q = zstd::decode_all(POS_QZ).expect("decode embedded positions");
        let cidx_b = zstd::decode_all(CIDX_Z).expect("decode embedded contour indices");
        let tidx_b = zstd::decode_all(TIDX_Z).expect("decode embedded triangle indices");
        let positions = bytes_to_f32_from_i16_le(&pos_q);
        let raw_indices = bytes_to_u32_le(&cidx_b);

        let contour_indices = split_long_chords(&positions, &raw_indices);
        // let triangle_indices = bytes_to_u32_le(TIDX); // If using unquantized data
        let triangle_indices = bytes_to_u32_le(&tidx_b); // quantized data
        Self {
            positions,
            contour_indices,
            triangle_indices,
        }
    }

    /// Number of vertices on the sphere.
    #[must_use]
    pub fn vertex_count(&self) -> usize {
        self.positions.len() / 3
    }

    /// Number of `LINE_STRIP` indices including synthesized restart markers.
    #[must_use]
    pub fn contour_index_count(&self) -> usize {
        self.contour_indices.len()
    }

    /// Number of land triangles (one per three indices).
    #[must_use]
    pub fn triangle_count(&self) -> usize {
        self.triangle_indices.len() / 3
    }
}

/// A globe widget driven by an externally-provided [`Camera`].
///
/// Renders by rasterizing land triangles and coastline contours into a shared
/// Braille dot grid (resolution `2W x 4H` for a `W x H` cell area), then
/// composing each cell's 8 dots into a single Braille glyph. Cells that
/// contain any contour dot render *only* the contour bits in
/// [`CONTOUR_COLOR`] - the land dots in those cells are dropped to preserve
/// the thin coastline. Cells with land but no contour render the land bits
/// in [`LAND_COLOR`].
///
/// Vertices on the back hemisphere (post-rotation `z < 0`) are culled and
/// contour segments straddling the horizon are clipped to it. Triangles are
/// culled by the sign of their average vertex z (a sphere-tessellation
/// approximation of the surface normal direction).
pub struct Globe<'a> {
    data: &'a MapData,
    camera: Camera,
}

impl<'a> Globe<'a> {
    #[must_use]
    pub fn new(data: &'a MapData, camera: Camera) -> Self {
        Self { data, camera }
    }
}

/// Dot-grid sentinels: one byte per dot. Empty means no fill, Land means inside
/// a front-facing land triangle, and Contour means a coastline line passes
/// through. Contour overwrites Land where they coincide.
const DOT_EMPTY: u8 = 0;
const DOT_LAND: u8 = 1;
const DOT_CONTOUR: u8 = 2;

/// Stipple mask AND-ed with the land dot bits in cells that don't contain any
/// contour dots. Picked as a 2-of-8 diagonal so a fully-covered cell renders
/// as a sparse two-dot speckle instead of `⣿` (solid), giving large continents
/// an open-stipple look. The bit `2*row + col` ordering is row-major to match
/// ratatui's `BRAILLE` table; the two on bits are top-right `(1, 0)` and
/// bottom-left `(0, 3)`, which tile into a diagonal pattern across cells.
const LAND_TEXTURE_MASK: u8 = 0b0100_0010;

// Reused across frames so a 60 FPS render doesn't reallocate a (W*2)*(H*4)
// byte buffer per tick. Thread-local because the TUI render path is
// single-threaded and we want to avoid synchronization on the hot path.
thread_local! {
    static DOT_SCRATCH: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
}

impl Widget for Globe<'_> {
    fn render(self, area: Rect, buf: &mut Buffer) {
        if area.width == 0 || area.height == 0 {
            return;
        }
        let (x_bounds, y_bounds) = canvas_bounds_for_round_globe(area, self.camera.zoom);
        let trig = TrigCache::new(self.camera);

        let aw = area.width as usize;
        let ah = area.height as usize;
        let dot_w = aw * 2;
        let dot_h = ah * 4;

        DOT_SCRATCH.with_borrow_mut(|dots| {
            dots.clear();
            dots.resize(dot_w * dot_h, DOT_EMPTY);

            let xspan = x_bounds[1] - x_bounds[0];
            let yspan = y_bounds[1] - y_bounds[0];
            let dot_w_f = dot_w as f64;
            let dot_h_f = dot_h as f64;
            let world_to_dot = |x: f64, y: f64| -> (f64, f64) {
                let dx = (x - x_bounds[0]) / xspan * dot_w_f;
                let dy = (y_bounds[1] - y) / yspan * dot_h_f;
                (dx, dy)
            };

            rasterize_triangles_into_dots(
                &self.data.triangle_indices,
                &self.data.positions,
                &trig,
                dots,
                dot_w,
                dot_h,
                &world_to_dot,
            );
            rasterize_contours_into_dots(self.data, self.camera, dots, dot_w, dot_h, &world_to_dot);
            compose_dots_into_buffer(dots, dot_w, area, buf);
        });
    }
}

fn rasterize_triangles_into_dots(
    indices: &[u32],
    positions: &[f32],
    trig: &TrigCache,
    dots: &mut [u8],
    dot_w: usize,
    dot_h: usize,
    world_to_dot: &impl Fn(f64, f64) -> (f64, f64),
) {
    let max_x = (dot_w as f64) - 1.0;
    let max_y = (dot_h as f64) - 1.0;
    for tri in indices.chunks_exact(3) {
        let v0 = rotate(read_vec3(positions, tri[0]), trig);
        let v1 = rotate(read_vec3(positions, tri[1]), trig);
        let v2 = rotate(read_vec3(positions, tri[2]), trig);
        // Approximate backface cull: vertices on the unit sphere are roughly
        // their own surface normals, so a negative average z means the
        // triangle's normal points away from the camera.
        if v0[2] + v1[2] + v2[2] < 0.0 {
            continue;
        }
        let (c0x, c0y) = world_to_dot(f64::from(v0[0]), f64::from(v0[1]));
        let (c1x, c1y) = world_to_dot(f64::from(v1[0]), f64::from(v1[1]));
        let (c2x, c2y) = world_to_dot(f64::from(v2[0]), f64::from(v2[1]));
        let min_x = c0x.min(c1x).min(c2x).floor().clamp(0.0, max_x) as usize;
        let max_xi = c0x.max(c1x).max(c2x).ceil().clamp(0.0, max_x) as usize;
        let min_y = c0y.min(c1y).min(c2y).floor().clamp(0.0, max_y) as usize;
        let max_yi = c0y.max(c1y).max(c2y).ceil().clamp(0.0, max_y) as usize;
        for dy in min_y..=max_yi {
            for dx in min_x..=max_xi {
                let px = dx as f64 + 0.5;
                let py = dy as f64 + 0.5;
                // Three edge functions; point is inside iff all three share a
                // sign (no winding-convention assumption).
                let e0 = (c1x - c0x) * (py - c0y) - (c1y - c0y) * (px - c0x);
                let e1 = (c2x - c1x) * (py - c1y) - (c2y - c1y) * (px - c1x);
                let e2 = (c0x - c2x) * (py - c2y) - (c0y - c2y) * (px - c2x);
                let inside =
                    (e0 >= 0.0 && e1 >= 0.0 && e2 >= 0.0) || (e0 <= 0.0 && e1 <= 0.0 && e2 <= 0.0);
                if inside {
                    dots[dy * dot_w + dx] = DOT_LAND;
                }
            }
        }
    }
}

fn rasterize_contours_into_dots(
    data: &MapData,
    camera: Camera,
    dots: &mut [u8],
    dot_w: usize,
    dot_h: usize,
    world_to_dot: &impl Fn(f64, f64) -> (f64, f64),
) {
    for [a, b] in visible_segments(data, camera) {
        let (ax, ay) = world_to_dot(a[0], a[1]);
        let (bx, by) = world_to_dot(b[0], b[1]);
        draw_line_into_dots(dots, dot_w, dot_h, ax, ay, bx, by);
    }
}

/// Bresenham line-rasterizer at integer dot coordinates. Out-of-grid samples
/// are silently dropped.
fn draw_line_into_dots(
    dots: &mut [u8],
    dot_w: usize,
    dot_h: usize,
    x0: f64,
    y0: f64,
    x1: f64,
    y1: f64,
) {
    let mut x = x0.round() as i32;
    let mut y = y0.round() as i32;
    let xe = x1.round() as i32;
    let ye = y1.round() as i32;
    let dx = (xe - x).abs();
    let dy = -(ye - y).abs();
    let sx: i32 = if x < xe { 1 } else { -1 };
    let sy: i32 = if y < ye { 1 } else { -1 };
    let mut err = dx + dy;
    loop {
        if x >= 0 && y >= 0 && (x as usize) < dot_w && (y as usize) < dot_h {
            dots[(y as usize) * dot_w + (x as usize)] = DOT_CONTOUR;
        }
        if x == xe && y == ye {
            break;
        }
        let e2 = 2 * err;
        if e2 >= dy {
            if x == xe {
                break;
            }
            err += dy;
            x += sx;
        }
        if e2 <= dx {
            if y == ye {
                break;
            }
            err += dx;
            y += sy;
        }
    }
}

fn compose_dots_into_buffer(dots: &[u8], dot_w: usize, area: Rect, buf: &mut Buffer) {
    let aw = area.width as usize;
    let ah = area.height as usize;
    for cy in 0..ah {
        for cx in 0..aw {
            // Collect land and contour dots into separate bitmasks. ratatui's
            // BRAILLE table is row-major: bit 0 is the top-left dot, bit 1 the
            // next to the right, and so on through the four rows.
            let mut land_bits: u8 = 0;
            let mut contour_bits: u8 = 0;
            for ddy in 0..4_usize {
                for ddx in 0..2_usize {
                    let dx = cx * 2 + ddx;
                    let dy = cy * 4 + ddy;
                    let bit = 1 << (ddy * 2 + ddx);
                    match dots[dy * dot_w + dx] {
                        DOT_LAND => land_bits |= bit,
                        DOT_CONTOUR => contour_bits |= bit,
                        _ => {}
                    }
                }
            }
            // If any contour dot lives in this cell, render the cell as the
            // contour line alone - don't OR in the land dots - so coastlines
            // keep their thin wireframe shape instead of fattening into a
            // mostly-solid square wherever they cross land. Otherwise, AND
            // the land dots with [`LAND_TEXTURE_MASK`] to break up flat
            // continent interiors.
            let (bits, color) = if contour_bits != 0 {
                (contour_bits, CONTOUR_COLOR)
            } else {
                let textured = land_bits & LAND_TEXTURE_MASK;
                if textured == 0 {
                    continue;
                }
                (textured, LAND_COLOR)
            };
            let ch = BRAILLE[bits as usize];
            if let Some(cell) = buf.cell_mut((area.x + cx as u16, area.y + cy as u16)) {
                cell.set_char(ch);
                cell.set_fg(color);
            }
        }
    }
}

/// Project a (latitude, longitude) point on the globe to the cell
/// containing it inside `area`, given the same camera the renderer
/// will use. Returns `None` when the point is on the back hemisphere
/// (hidden behind the globe) or projects outside `area` - callers
/// should treat both as "don't draw an overlay".
///
/// Latitude / longitude are in degrees, with the same sign convention
/// as the daemon's `GeoIpLocation`: positive latitude = north,
/// positive longitude = east. The geometry's coordinate convention is
/// `x = cos(φ)·sin(λ)`, `y = sin(φ)`, `z = cos(φ)·cos(λ)`, matching
/// the rotation chain in [`Camera`].
#[must_use]
pub fn project_point(lat_deg: f32, lon_deg: f32, camera: Camera, area: Rect) -> Option<(u16, u16)> {
    if area.width == 0 || area.height == 0 {
        return None;
    }
    let lat = lat_deg.to_radians();
    let lon = lon_deg.to_radians();
    let v = [lat.cos() * lon.sin(), lat.sin(), lat.cos() * lon.cos()];
    let trig = TrigCache::new(camera);
    let r = rotate(v, &trig);
    if r[2] < 0.0 {
        // Behind the globe - caller shouldn't paint a marker through it.
        return None;
    }
    let (x_bounds, y_bounds) = canvas_bounds_for_round_globe(area, camera.zoom);
    let dot_w = f64::from(area.width) * 2.0;
    let dot_h = f64::from(area.height) * 4.0;
    let xspan = x_bounds[1] - x_bounds[0];
    let yspan = y_bounds[1] - y_bounds[0];
    let dx = (f64::from(r[0]) - x_bounds[0]) / xspan * dot_w;
    let dy = (y_bounds[1] - f64::from(r[1])) / yspan * dot_h;
    if dx < 0.0 || dy < 0.0 || dx >= dot_w || dy >= dot_h {
        return None;
    }
    // Each cell is 2 dots wide x 4 tall; floor the dot position to the
    // containing cell, then offset by `area`'s top-left.
    let cell_x = area.x + (dx as u16) / 2;
    let cell_y = area.y + (dy as u16) / 4;
    Some((cell_x, cell_y))
}

/// Picks `x_bounds` / `y_bounds` for a `Canvas` such that a unit-radius circle
/// inscribed at the origin renders round in the given area, accounting for
/// terminal cells being roughly twice as tall as they are wide. Bounds are
/// scaled by `1 / zoom` - increasing zoom shrinks the visible world rectangle,
/// magnifying the globe.
fn canvas_bounds_for_round_globe(area: Rect, zoom: f32) -> ([f64; 2], [f64; 2]) {
    // The unit sphere lives in [-1, 1]; pad so the limb isn't clipped at zoom = 1.
    const RADIUS: f64 = 1.05;
    // Approximate ratio of a terminal cell's pixel-height to its pixel-width.
    // 2.0 is a reasonable default across common terminals/fonts.
    const CELL_ASPECT: f64 = 2.0;

    let area_w = f64::from(area.width.max(1));
    let area_h = f64::from(area.height.max(1));
    let pixel_aspect = area_w / (area_h * CELL_ASPECT);
    let scale = RADIUS / f64::from(zoom.max(1e-3));
    if pixel_aspect >= 1.0 {
        (
            [-scale * pixel_aspect, scale * pixel_aspect],
            [-scale, scale],
        )
    } else {
        (
            [-scale, scale],
            [-scale / pixel_aspect, scale / pixel_aspect],
        )
    }
}

/// Yields each contour line segment, rotated to camera space, with the back
/// hemisphere culled and segments that straddle the horizon clipped at `z = 0`.
fn visible_segments(data: &MapData, camera: Camera) -> impl Iterator<Item = [[f64; 2]; 2]> + '_ {
    let trig = TrigCache::new(camera);
    data.contour_indices.windows(2).filter_map(move |w| {
        let (i, j) = (w[0], w[1]);
        if i == RESTART || j == RESTART {
            return None;
        }
        let a = rotate(read_vec3(&data.positions, i), &trig);
        let b = rotate(read_vec3(&data.positions, j), &trig);
        cull_and_clip(a, b)
    })
}

/// Precomputed sin/cos of yaw and pitch, hoisted out of the inner per-vertex loop.
#[derive(Debug, Clone, Copy)]
struct TrigCache {
    sy: f32,
    cy: f32,
    sp: f32,
    cp: f32,
}

impl TrigCache {
    fn new(camera: Camera) -> Self {
        let (sy, cy) = camera.yaw.sin_cos();
        let (sp, cp) = camera.pitch.sin_cos();
        Self { sy, cy, sp, cp }
    }
}

/// Returns the visible portion of segment `a -> b` projected to 2D, after
/// culling the back hemisphere (`z < 0`) and clipping segments that straddle
/// the horizon plane.
///
/// The camera looks down the -Z axis, so a vertex is front-facing iff its
/// post-rotation `z` is non-negative. The four cases:
/// - both endpoints in front -> return as-is
/// - both endpoints behind -> cull
/// - one endpoint in front, one behind -> keep the front endpoint and clip the other to the great
///   circle at `z = 0` (the silhouette of the globe)
fn cull_and_clip(a: [f32; 3], b: [f32; 3]) -> Option<[[f64; 2]; 2]> {
    let project = |v: [f32; 3]| [f64::from(v[0]), f64::from(v[1])];
    match (a[2] >= 0.0, b[2] >= 0.0) {
        (true, true) => Some([project(a), project(b)]),
        (false, false) => None,
        (visible_a, _) => {
            let (front, back) = if visible_a { (a, b) } else { (b, a) };
            // front[2] >= 0 and back[2] < 0, so the denominator is strictly
            // positive and t lies in [0, 1).
            let t = front[2] / (front[2] - back[2]);
            let cx = front[0] + t * (back[0] - front[0]);
            let cy = front[1] + t * (back[1] - front[1]);
            Some([project(front), [f64::from(cx), f64::from(cy)]])
        }
    }
}

fn read_vec3(positions: &[f32], i: u32) -> [f32; 3] {
    let base = (i as usize) * 3;
    [positions[base], positions[base + 1], positions[base + 2]]
}

/// Walks a `LINE_STRIP` index buffer and inserts a [`RESTART`] marker before
/// any index whose 3D chord to the previous index exceeds [`MAX_CHORD`].
///
/// The shipped contour buffer has no native restart markers - it's a single
/// 98k-index strip, and the desktop app relies on opaque-triangle depth
/// occlusion to hide the resulting cross-continent stitches. This function
/// synthesizes the missing boundaries so the wireframe renders cleanly.
fn split_long_chords(positions: &[f32], indices: &[u32]) -> Vec<u32> {
    let max_chord_sq = MAX_CHORD * MAX_CHORD;
    let mut out = Vec::with_capacity(indices.len() + indices.len() / 64);
    let mut prev: Option<u32> = None;
    for &i in indices {
        if i == RESTART {
            out.push(i);
            prev = None;
            continue;
        }
        if let Some(p) = prev {
            let a = read_vec3(positions, p);
            let b = read_vec3(positions, i);
            let dx = a[0] - b[0];
            let dy = a[1] - b[1];
            let dz = a[2] - b[2];
            if dx * dx + dy * dy + dz * dz > max_chord_sq {
                out.push(RESTART);
            }
        }
        out.push(i);
        prev = Some(i);
    }
    out
}

/// Applies yaw (around Y) followed by pitch (around X) to a 3D vertex.
///
/// Yaw spins the globe horizontally; pitch tilts it forward/back. With both
/// sin/cos pairs precomputed in a [`TrigCache`], this is six multiplies and
/// two subtractions per vertex.
fn rotate(v: [f32; 3], t: &TrigCache) -> [f32; 3] {
    let [x, y, z] = v;
    // Y rotation (yaw)
    let xr = t.cy * x + t.sy * z;
    let zr = -t.sy * x + t.cy * z;
    // X rotation (pitch)
    let yr2 = t.cp * y - t.sp * zr;
    let zr2 = t.sp * y + t.cp * zr;
    [xr, yr2, zr2]
}

/// Dequantize i16-encoded unit-sphere coordinates: each axis was multiplied
/// by 32767 and rounded at build time; here we reverse that.
fn bytes_to_f32_from_i16_le(b: &[u8]) -> Vec<f32> {
    assert!(
        b.len().is_multiple_of(2),
        "i16 buffer length not a multiple of 2: {} bytes",
        b.len()
    );
    const INV: f32 = 1.0 / 32767.0;
    b.chunks_exact(2)
        .map(|c| f32::from(i16::from_le_bytes([c[0], c[1]])) * INV)
        .collect()
}

fn bytes_to_f32_le(b: &[u8]) -> Vec<f32> {
    assert!(
        b.len().is_multiple_of(4),
        "f32 buffer length not a multiple of 4: {} bytes",
        b.len()
    );
    b.chunks_exact(4)
        .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
        .collect()
}

fn bytes_to_u32_le(b: &[u8]) -> Vec<u32> {
    assert!(
        b.len().is_multiple_of(4),
        "u32 buffer length not a multiple of 4: {} bytes",
        b.len()
    );
    b.chunks_exact(4)
        .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]]))
        .collect()
}

fn read_f32_le(path: &Path) -> io::Result<Vec<f32>> {
    Ok(bytes_to_f32_le(&fs::read(path)?))
}

fn read_u32_le(path: &Path) -> io::Result<Vec<u32>> {
    Ok(bytes_to_u32_le(&fs::read(path)?))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn embedded_data_parses_to_a_thin_sphere_shell() {
        let map = MapData::embedded();
        assert!(map.vertex_count() > 1000, "expected many vertices");
        assert!(map.contour_index_count() > 1000, "expected many indices");
        // The contour data isn't strictly on a unit sphere - some subdivided
        // segments sit at radius ~0.99 to render slightly above the land
        // triangle layer (which the desktop app scales to 0.99999). Just
        // assert all vertices lie in a thin shell near the unit sphere.
        for chunk in map.positions.chunks_exact(3) {
            let r2 = chunk[0] * chunk[0] + chunk[1] * chunk[1] + chunk[2] * chunk[2];
            assert!(
                (0.95..=1.05).contains(&r2),
                "vertex outside expected shell: r^2 = {r2}"
            );
        }
    }

    #[test]
    fn rotate_preserves_length() {
        let camera = Camera {
            yaw: 1.234,
            pitch: -0.5,
            zoom: 1.0,
        };
        let trig = TrigCache::new(camera);
        let v = [0.6_f32, 0.5, -0.624_499_8];
        let r = rotate(v, &trig);
        let len2 = |a: [f32; 3]| a[0] * a[0] + a[1] * a[1] + a[2] * a[2];
        assert!((len2(v) - len2(r)).abs() < 1e-5);
    }

    #[test]
    fn rotate_with_zero_pitch_matches_yaw_only() {
        // With pitch = 0, the new function should agree with a pure Y rotation.
        let camera = Camera {
            yaw: 0.7,
            pitch: 0.0,
            zoom: 1.0,
        };
        let trig = TrigCache::new(camera);
        let v = [1.0_f32, 0.0, 0.0];
        let r = rotate(v, &trig);
        let (sy, cy) = 0.7_f32.sin_cos();
        let expected = [cy, 0.0, -sy];
        for i in 0..3 {
            assert!((r[i] - expected[i]).abs() < 1e-6);
        }
    }

    #[test]
    fn cull_keeps_segments_fully_in_front() {
        let r = cull_and_clip([0.5, 0.0, 0.5], [-0.5, 0.0, 0.5]).unwrap();
        assert!((r[0][0] - 0.5).abs() < 1e-6);
        assert!((r[1][0] - (-0.5)).abs() < 1e-6);
    }

    #[test]
    fn cull_drops_segments_fully_behind() {
        assert!(cull_and_clip([0.5, 0.0, -0.5], [-0.5, 0.0, -0.5]).is_none());
    }

    #[test]
    fn cull_clips_segments_at_the_horizon() {
        // Front endpoint at x=0,z=+0.5; back at x=1,z=-0.5.
        // The horizon crossing is halfway: t = 0.5/(0.5 - -0.5) = 0.5,
        // so x at the clip is 0.0 + 0.5*(1.0 - 0.0) = 0.5.
        let r = cull_and_clip([0.0, 0.0, 0.5], [1.0, 0.0, -0.5]).unwrap();
        assert!((r[0][0] - 0.0).abs() < 1e-6, "front endpoint preserved");
        assert!(
            (r[1][0] - 0.5).abs() < 1e-6,
            "back endpoint clipped to horizon"
        );
    }

    #[test]
    fn split_long_chords_inserts_restart_markers_in_the_shipped_data() {
        // The raw shipped buffer has zero restart markers, so any non-zero
        // count proves the synthesis ran and acted on real data.
        let map = MapData::embedded();
        let restarts = map
            .contour_indices
            .iter()
            .filter(|&&i| i == RESTART)
            .count();
        assert!(
            restarts > 100,
            "expected many synthesized restarts, got {restarts}"
        );
    }

    #[test]
    fn no_segment_in_the_shipped_data_exceeds_the_chord_threshold() {
        let map = MapData::embedded();
        let mut max_sq = 0.0_f32;
        for w in map.contour_indices.windows(2) {
            if w[0] == RESTART || w[1] == RESTART {
                continue;
            }
            let a = read_vec3(&map.positions, w[0]);
            let b = read_vec3(&map.positions, w[1]);
            let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
            let d2 = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
            if d2 > max_sq {
                max_sq = d2;
            }
        }
        assert!(
            max_sq <= MAX_CHORD * MAX_CHORD,
            "longest surviving chord {} exceeds threshold {MAX_CHORD}",
            max_sq.sqrt()
        );
    }

    #[test]
    fn cull_handles_either_endpoint_being_the_back_one() {
        // Same geometry, swapped argument order - should produce the same segment.
        let forward = cull_and_clip([0.0, 0.0, 0.5], [1.0, 0.0, -0.5]).unwrap();
        let reverse = cull_and_clip([1.0, 0.0, -0.5], [0.0, 0.0, 0.5]).unwrap();
        // The "front-first" element should always be the visible vertex.
        assert!((forward[0][0] - reverse[0][0]).abs() < 1e-6);
        assert!((forward[1][0] - reverse[1][0]).abs() < 1e-6);
    }
}