use std::{cell::RefCell, fs, io, path::Path};
use ratatui::{
buffer::Buffer, layout::Rect, style::Color, symbols::braille::BRAILLE, widgets::Widget,
};
const RESTART: u32 = u32::MAX;
const MAX_CHORD: f32 = 0.05;
pub const CONTOUR_COLOR: Color = Color::Indexed(74);
pub const LAND_COLOR: Color = Color::Indexed(28);
#[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,
}
}
}
#[derive(Debug, Clone)]
pub struct MapData {
positions: Vec<f32>,
contour_indices: Vec<u32>,
triangle_indices: Vec<u32>,
}
impl MapData {
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,
})
}
#[must_use]
pub fn embedded() -> Self {
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_b); Self {
positions,
contour_indices,
triangle_indices,
}
}
#[must_use]
pub fn vertex_count(&self) -> usize {
self.positions.len() / 3
}
#[must_use]
pub fn contour_index_count(&self) -> usize {
self.contour_indices.len()
}
#[must_use]
pub fn triangle_count(&self) -> usize {
self.triangle_indices.len() / 3
}
}
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 }
}
}
const DOT_EMPTY: u8 = 0;
const DOT_LAND: u8 = 1;
const DOT_CONTOUR: u8 = 2;
const LAND_TEXTURE_MASK: u8 = 0b0100_0010;
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);
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;
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);
}
}
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 {
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,
_ => {}
}
}
}
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);
}
}
}
}
#[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 {
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;
}
let cell_x = area.x + (dx as u16) / 2;
let cell_y = area.y + (dy as u16) / 4;
Some((cell_x, cell_y))
}
fn canvas_bounds_for_round_globe(area: Rect, zoom: f32) -> ([f64; 2], [f64; 2]) {
const RADIUS: f64 = 1.05;
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],
)
}
}
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)
})
}
#[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 }
}
}
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) };
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]]
}
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
}
fn rotate(v: [f32; 3], t: &TrigCache) -> [f32; 3] {
let [x, y, z] = v;
let xr = t.cy * x + t.sy * z;
let zr = -t.sy * x + t.cy * z;
let yr2 = t.cp * y - t.sp * zr;
let zr2 = t.sp * y + t.cp * zr;
[xr, yr2, zr2]
}
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");
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() {
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() {
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() {
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() {
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();
assert!((forward[0][0] - reverse[0][0]).abs() < 1e-6);
assert!((forward[1][0] - reverse[1][0]).abs() < 1e-6);
}
}