1use std::{cell::RefCell, fs, io, path::Path};
21
22use ratatui::{
23 buffer::Buffer, layout::Rect, style::Color, symbols::braille::BRAILLE, widgets::Widget,
24};
25
26const RESTART: u32 = u32::MAX;
31
32const MAX_CHORD: f32 = 0.05;
38
39pub const CONTOUR_COLOR: Color = Color::Indexed(74);
41
42pub const LAND_COLOR: Color = Color::Indexed(28);
44
45#[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#[derive(Debug, Clone)]
69pub struct MapData {
70 positions: Vec<f32>,
72 contour_indices: Vec<u32>,
75 triangle_indices: Vec<u32>,
78}
79
80impl MapData {
81 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 #[must_use]
101 pub fn embedded() -> Self {
102 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_b); Self {
132 positions,
133 contour_indices,
134 triangle_indices,
135 }
136 }
137
138 #[must_use]
140 pub fn vertex_count(&self) -> usize {
141 self.positions.len() / 3
142 }
143
144 #[must_use]
146 pub fn contour_index_count(&self) -> usize {
147 self.contour_indices.len()
148 }
149
150 #[must_use]
152 pub fn triangle_count(&self) -> usize {
153 self.triangle_indices.len() / 3
154 }
155}
156
157pub 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
183const DOT_EMPTY: u8 = 0;
187const DOT_LAND: u8 = 1;
188const DOT_CONTOUR: u8 = 2;
189
190const LAND_TEXTURE_MASK: u8 = 0b0100_0010;
197
198thread_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 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 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
309fn 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 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 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#[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 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 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
442fn canvas_bounds_for_round_globe(area: Rect, zoom: f32) -> ([f64; 2], [f64; 2]) {
448 const RADIUS: f64 = 1.05;
450 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
471fn 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#[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
503fn 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 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
535fn 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
568fn rotate(v: [f32; 3], t: &TrigCache) -> [f32; 3] {
574 let [x, y, z] = v;
575 let xr = t.cy * x + t.sy * z;
577 let zr = -t.sy * x + t.cy * z;
578 let yr2 = t.cp * y - t.sp * zr;
580 let zr2 = t.sp * y + t.cp * zr;
581 [xr, yr2, zr2]
582}
583
584fn 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 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 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 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 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 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 assert!((forward[0][0] - reverse[0][0]).abs() < 1e-6);
753 assert!((forward[1][0] - reverse[1][0]).abs() < 1e-6);
754 }
755}