1use crate::rotation::{CoordSystem, Rotation, coord_rotation};
2use std::f64::consts::PI;
3
4fn generate_graticule_degrees(spacing_deg: f64, is_latitude: bool) -> Vec<f64> {
15 let mut degrees: Vec<f64> = Vec::new();
16
17 if is_latitude {
18 degrees.push(0.0); degrees.push(90.0); degrees.push(-90.0); let mut deg = spacing_deg;
25 while deg < 90.0 {
26 degrees.push(deg);
27 degrees.push(-deg);
28 deg += spacing_deg;
29 }
30 } else {
31 degrees.push(0.0); degrees.push(90.0); degrees.push(180.0); degrees.push(270.0); let mut deg = spacing_deg;
39 while deg < 360.0 {
40 degrees.push(deg);
41 deg += spacing_deg;
42 }
43 }
44
45 degrees.sort_unstable_by(|a: &f64, b: &f64| a.partial_cmp(b).unwrap());
47 degrees.dedup_by(|a: &mut f64, b: &mut f64| (*a - *b).abs() < 0.01);
48 degrees
49}
50
51#[derive(Clone, Debug)]
53pub struct GraticulePolyline {
54 pub points: Vec<(f64, f64)>,
55}
56
57impl Default for GraticulePolyline {
58 fn default() -> Self {
59 Self::new()
60 }
61}
62
63impl GraticulePolyline {
64 pub fn new() -> Self {
65 Self { points: Vec::new() }
66 }
67
68 pub fn is_empty(&self) -> bool {
69 self.points.is_empty()
70 }
71
72 pub fn len(&self) -> usize {
73 self.points.len()
74 }
75
76 pub fn push(&mut self, point: (f64, f64)) {
77 self.points.push(point);
78 }
79}
80
81#[derive(Clone, Debug)]
83pub struct GraticuleLineSegments {
84 pub polylines: Vec<GraticulePolyline>,
85}
86
87impl Default for GraticuleLineSegments {
88 fn default() -> Self {
89 Self::new()
90 }
91}
92
93impl GraticuleLineSegments {
94 pub fn new() -> Self {
95 Self {
96 polylines: Vec::new(),
97 }
98 }
99
100 pub fn add_polyline(&mut self, polyline: GraticulePolyline) {
101 if !polyline.is_empty() {
102 self.polylines.push(polyline);
103 }
104 }
105}
106
107pub struct GraticuleTransform {
113 grat_to_input: Rotation,
115 view: Option<Rotation>,
117}
118
119impl GraticuleTransform {
120 pub fn new(
121 graticule_coord: CoordSystem,
122 input_coord: CoordSystem,
123 view: Option<Rotation>,
124 ) -> Self {
125 let grat_to_input = coord_rotation(graticule_coord, input_coord);
126 Self {
127 grat_to_input,
128 view,
129 }
130 }
131
132 pub fn apply(&self, lon: f64, lat: f64) -> [f64; 3] {
139 let v = lonlat_to_vec(lon, lat);
141
142 let mut v = self.grat_to_input.apply(v);
144
145 if let Some(view_rot) = &self.view {
147 v = view_rot.apply(v);
148 }
149
150 v
151 }
152}
153
154#[inline]
157fn lonlat_to_vec(lon: f64, lat: f64) -> [f64; 3] {
158 let cos_lat = lat.cos();
159 [cos_lat * lon.cos(), cos_lat * lon.sin(), lat.sin()]
160}
161
162#[inline]
165fn vec_to_lonlat(v: [f64; 3]) -> (f64, f64) {
166 let r = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
167 let lon = v[1].atan2(v[0]); let lat = (v[2] / r).asin();
169 (lon, lat)
170}
171
172fn estimate_max_jump(segment: &[(f64, f64)], _param_step: f64) -> f64 {
178 if segment.len() < 2 {
179 return 0.15;
181 }
182
183 let (u1, v1) = segment[segment.len() - 2];
185 let (u2, v2) = segment[segment.len() - 1];
186
187 let du = (u2 - u1).abs();
188 let dv = (v2 - v1).abs();
189 let dist = (du * du + dv * dv).sqrt();
190
191 (dist * 3.0).max(0.05) }
195
196pub fn render_graticule_mollweide(
201 grid: &mut crate::render::raster::RasterGrid,
202 view: &crate::rotation::ViewTransform,
203 dpar_deg: f64, dmer_deg: f64, grat_coord: CoordSystem,
206 input_coord: CoordSystem,
207) {
208 use crate::mollweide::MollweideProjection;
209 use crate::projection::Projection;
210
211 let transform = GraticuleTransform::new(grat_coord, input_coord, None);
212 let proj = MollweideProjection;
213
214 let meridian_degrees = generate_graticule_degrees(dmer_deg, false);
218 let parallel_degrees = generate_graticule_degrees(dpar_deg, true);
219
220 for &mer_deg_start in &meridian_degrees {
222 let lon_grat = mer_deg_start * PI / 180.0;
223 let mut line_segments: Vec<Vec<(f64, f64)>> = Vec::new();
224 let mut current_segment: Vec<(f64, f64)> = Vec::new();
225
226 for par_deg in (-90..=90).step_by(2) {
229 let lat_grat = par_deg as f64 * PI / 180.0;
230
231 let v_final = transform.apply(lon_grat, lat_grat);
233
234 let v_viewed = view.apply(v_final);
236
237 let (lon_final, lat_final) = vec_to_lonlat(v_viewed);
239
240 if let Some((u, v)) = proj.forward(lon_final, lat_final) {
242 if !current_segment.is_empty()
244 && let Some(last_point) = current_segment.last()
245 {
246 let prev_u = last_point.0;
247 let prev_v = last_point.1;
248
249 let du = (u - prev_u).abs();
250 let dv = (v - prev_v).abs();
251 let jump_dist = (du * du + dv * dv).sqrt();
252
253 let param_step = 2.0; let max_expected = estimate_max_jump(¤t_segment, param_step);
256
257 if jump_dist > max_expected {
259 if current_segment.len() > 1 {
261 line_segments.push(current_segment);
262 }
263 current_segment = Vec::new();
264 }
265 }
266 current_segment.push((u, v));
267 } else {
268 if current_segment.len() > 1 {
270 line_segments.push(current_segment);
271 }
272 current_segment = Vec::new();
273 }
274 }
275
276 if current_segment.len() > 1 {
278 line_segments.push(current_segment);
279 }
280
281 for segment in line_segments {
283 for window in segment.windows(2) {
284 draw_line_on_grid(grid, window[0].0, window[0].1, window[1].0, window[1].1);
285 }
286 }
287 }
288
289 for &par_deg in ¶llel_degrees {
291 let lat_grat = par_deg * PI / 180.0;
292 let mut line_segments: Vec<Vec<(f64, f64)>> = Vec::new();
293 let mut current_segment: Vec<(f64, f64)> = Vec::new();
294
295 let is_pole = (par_deg - 90.0).abs() < 0.1 || (par_deg + 90.0).abs() < 0.1;
298
299 if is_pole {
300 let lon_grat = 0.0;
302 let v_final = transform.apply(lon_grat, lat_grat);
303 let v_viewed = view.apply(v_final);
304 let (_lon_final, _lat_final) = vec_to_lonlat(v_viewed);
305
306 } else {
310 let mut mer_deg_float = 0.0;
312 while mer_deg_float < 360.0 {
313 let lon_grat = mer_deg_float * PI / 180.0;
314
315 let v_final = transform.apply(lon_grat, lat_grat);
317
318 let v_viewed = view.apply(v_final);
320
321 let (lon_final, lat_final) = vec_to_lonlat(v_viewed);
323
324 if let Some((u, v)) = proj.forward(lon_final, lat_final) {
326 if !current_segment.is_empty()
328 && let Some(last_point) = current_segment.last()
329 {
330 let prev_u = last_point.0;
331 let prev_v = last_point.1;
332
333 let du = (u - prev_u).abs();
334 let dv = (v - prev_v).abs();
335 let jump_dist = (du * du + dv * dv).sqrt();
336
337 let param_step = 0.5; let max_expected = estimate_max_jump(¤t_segment, param_step);
340
341 if jump_dist > max_expected {
343 if current_segment.len() > 1 {
345 line_segments.push(current_segment);
346 }
347 current_segment = Vec::new();
348 }
349 }
350 current_segment.push((u, v));
351 } else {
352 if current_segment.len() > 1 {
354 line_segments.push(current_segment);
355 }
356 current_segment = Vec::new();
357 }
358
359 mer_deg_float += 0.5;
360 }
361
362 if current_segment.len() > 1 {
364 line_segments.push(current_segment);
365 }
366
367 for segment in line_segments {
369 for window in segment.windows(2) {
370 draw_line_on_grid(grid, window[0].0, window[0].1, window[1].0, window[1].1);
371 }
372 }
373 }
374 }
375}
376
377fn render_graticule_vectorized_generic<P: crate::projection::Projection>(
382 proj: &P,
383 view: &crate::rotation::ViewTransform,
384 dpar_deg: f64, dmer_deg: f64, grat_coord: CoordSystem,
387 input_coord: CoordSystem,
388) -> GraticuleLineSegments {
389 let transform = GraticuleTransform::new(grat_coord, input_coord, None);
390
391 let mut result = GraticuleLineSegments::new();
392
393 let meridian_degrees = generate_graticule_degrees(dmer_deg, false);
395 let parallel_degrees = generate_graticule_degrees(dpar_deg, true);
396
397 for &mer_deg_start in &meridian_degrees {
399 let lon_grat = mer_deg_start * PI / 180.0;
400 let mut line_segments: Vec<Vec<(f64, f64)>> = Vec::new();
401 let mut current_segment: Vec<(f64, f64)> = Vec::new();
402
403 for par_deg in (-90..=90).step_by(2) {
405 let lat_grat = par_deg as f64 * PI / 180.0;
406
407 let v_final = transform.apply(lon_grat, lat_grat);
409
410 let v_viewed = view.apply(v_final);
412
413 let (lon_final, lat_final) = vec_to_lonlat(v_viewed);
415
416 if let Some((u, v)) = proj.forward(lon_final, lat_final) {
418 if !current_segment.is_empty()
420 && let Some(last_point) = current_segment.last()
421 {
422 let prev_u = last_point.0;
423 let prev_v = last_point.1;
424
425 let du = (u - prev_u).abs();
426 let dv = (v - prev_v).abs();
427 let jump_dist = (du * du + dv * dv).sqrt();
428
429 let param_step = 2.0; let max_expected = estimate_max_jump(¤t_segment, param_step);
432
433 if jump_dist > max_expected {
435 if current_segment.len() > 1 {
437 line_segments.push(current_segment);
438 }
439 current_segment = Vec::new();
440 }
441 }
442 current_segment.push((u, v));
443 } else {
444 if current_segment.len() > 1 {
446 line_segments.push(current_segment);
447 }
448 current_segment = Vec::new();
449 }
450 }
451
452 if current_segment.len() > 1 {
454 line_segments.push(current_segment);
455 }
456
457 for segment in line_segments {
459 let mut polyline = GraticulePolyline::new();
460 for point in segment {
461 polyline.push(point);
462 }
463 result.add_polyline(polyline);
464 }
465 }
466
467 for &par_deg in ¶llel_degrees {
469 let lat_grat = par_deg * PI / 180.0;
470
471 let is_pole = (par_deg - 90.0).abs() < 0.1 || (par_deg + 90.0).abs() < 0.1;
473
474 if is_pole {
475 continue;
477 }
478
479 let mut line_segments: Vec<Vec<(f64, f64)>> = Vec::new();
480 let mut current_segment: Vec<(f64, f64)> = Vec::new();
481
482 let mut mer_deg_float = 0.0;
484 while mer_deg_float < 360.0 {
485 let lon_grat = mer_deg_float * PI / 180.0;
486
487 let v_final = transform.apply(lon_grat, lat_grat);
489
490 let v_viewed = view.apply(v_final);
492
493 let (lon_final, lat_final) = vec_to_lonlat(v_viewed);
495
496 if let Some((u, v)) = proj.forward(lon_final, lat_final) {
498 if !current_segment.is_empty()
500 && let Some(last_point) = current_segment.last()
501 {
502 let prev_u = last_point.0;
503 let prev_v = last_point.1;
504
505 let du = (u - prev_u).abs();
506 let dv = (v - prev_v).abs();
507 let jump_dist = (du * du + dv * dv).sqrt();
508
509 let param_step = 0.5; let max_expected = estimate_max_jump(¤t_segment, param_step);
512
513 if jump_dist > max_expected {
515 if current_segment.len() > 1 {
517 line_segments.push(current_segment);
518 }
519 current_segment = Vec::new();
520 }
521 }
522 current_segment.push((u, v));
523 } else {
524 if current_segment.len() > 1 {
526 line_segments.push(current_segment);
527 }
528 current_segment = Vec::new();
529 }
530
531 mer_deg_float += 0.5;
532 }
533
534 if current_segment.len() > 1 {
536 line_segments.push(current_segment);
537 }
538
539 for segment in line_segments {
541 let mut polyline = GraticulePolyline::new();
542 for point in segment {
543 polyline.push(point);
544 }
545 result.add_polyline(polyline);
546 }
547 }
548
549 result
550}
551
552pub fn render_graticule_mollweide_vectorized(
556 view: &crate::rotation::ViewTransform,
557 dpar_deg: f64, dmer_deg: f64, grat_coord: CoordSystem,
560 input_coord: CoordSystem,
561) -> GraticuleLineSegments {
562 use crate::mollweide::MollweideProjection;
563
564 let proj = MollweideProjection;
565 render_graticule_vectorized_generic(&proj, view, dpar_deg, dmer_deg, grat_coord, input_coord)
566}
567
568pub fn render_graticule_hammer_vectorized(
572 view: &crate::rotation::ViewTransform,
573 dpar_deg: f64, dmer_deg: f64, grat_coord: CoordSystem,
576 input_coord: CoordSystem,
577) -> GraticuleLineSegments {
578 use crate::hammer::HammerProjection;
579
580 let proj = HammerProjection::new();
581 render_graticule_vectorized_generic(&proj, view, dpar_deg, dmer_deg, grat_coord, input_coord)
582}
583
584pub fn render_graticule_cairo(
589 graticule: &GraticuleLineSegments,
590 cr: &cairo::Context,
591 x_offset: f64,
592 y_offset: f64,
593 width: f64,
594 height: f64,
595) {
596 render_graticule_cairo_with_color(
597 graticule,
598 cr,
599 crate::params::GeometryRect {
600 x: x_offset,
601 y: y_offset,
602 w: width,
603 h: height,
604 },
605 (0.0, 0.0, 0.0),
606 );
607}
608
609pub fn render_graticule_cairo_with_color(
611 graticule: &GraticuleLineSegments,
612 cr: &cairo::Context,
613 layout: crate::params::GeometryRect,
614 color: (f64, f64, f64),
615) {
616 cr.set_source_rgb(color.0, color.1, color.2);
618 cr.set_line_width(0.5); for polyline in &graticule.polylines {
621 if polyline.is_empty() {
622 continue;
623 }
624
625 let first = polyline.points[0];
627 let x = layout.x + first.0 * layout.w;
628 let y = layout.y + first.1 * layout.h;
629 cr.move_to(x, y);
630
631 for &(u, v) in &polyline.points[1..] {
633 let x = layout.x + u * layout.w;
634 let y = layout.y + v * layout.h;
635 cr.line_to(x, y);
636 }
637 }
638
639 let _ = cr.stroke();
641}
642
643fn draw_line_on_grid(
645 grid: &mut crate::render::raster::RasterGrid,
646 u0: f64,
647 v0: f64,
648 u1: f64,
649 v1: f64,
650) {
651 use image::Rgba;
652
653 let (x0, y0) = (
654 (u0 * (grid.width - 1) as f64) as i32,
655 (v0 * (grid.height - 1) as f64) as i32,
656 );
657 let (x1, y1) = (
658 (u1 * (grid.width - 1) as f64) as i32,
659 (v1 * (grid.height - 1) as f64) as i32,
660 );
661
662 let steps = (x1 - x0).abs().max((y1 - y0).abs()) as usize;
664 if steps == 0 {
665 return;
666 }
667
668 for i in 0..=steps {
669 let t = i as f64 / steps as f64;
670 let px = ((1.0 - t) * x0 as f64 + t * x1 as f64).round() as u32;
671 let py = ((1.0 - t) * y0 as f64 + t * y1 as f64).round() as u32;
672
673 if px < grid.width && py < grid.height {
674 grid.set_pixel(px, py, Rgba([0, 0, 0, 255])); }
676 }
677}
678
679#[cfg(test)]
680mod tests {
681 use super::*;
682 use crate::rotation::{DEG2RAD, RAD2DEG};
683
684 fn test_point_transformation(
694 input_coord: CoordSystem,
695 graticule_coord: CoordSystem,
696 grat_lon_deg: f64,
697 grat_lat_deg: f64,
698 expected_lon_deg: f64,
699 expected_lat_deg: f64,
700 tolerance_deg: f64,
701 ) {
702 let transform = GraticuleTransform::new(graticule_coord, input_coord, None);
703 let v = transform.apply(grat_lon_deg * DEG2RAD, grat_lat_deg * DEG2RAD);
704 let (result_lon, result_lat) = vec_to_lonlat(v);
705
706 let expected_lon_rad = expected_lon_deg.rem_euclid(360.0) * DEG2RAD;
708 let result_lon_normalized = if result_lon > PI {
709 result_lon - 2.0 * PI
710 } else if result_lon < -PI {
711 result_lon + 2.0 * PI
712 } else {
713 result_lon
714 };
715
716 let lon_diff = (result_lon_normalized - expected_lon_rad).abs();
717 let lat_diff = (result_lat - expected_lat_deg * DEG2RAD).abs();
718
719 let tolerance_rad = tolerance_deg * DEG2RAD;
720
721 assert!(
722 lon_diff < tolerance_rad || (2.0 * PI - lon_diff) < tolerance_rad,
723 "Longitude mismatch for ({}, {}) in {:?} → {:?}: got {:.4}°, expected {:.4}°",
724 grat_lon_deg,
725 grat_lat_deg,
726 graticule_coord,
727 input_coord,
728 result_lon_normalized * RAD2DEG,
729 expected_lon_deg
730 );
731
732 assert!(
733 lat_diff < tolerance_rad,
734 "Latitude mismatch for ({}, {}) in {:?} → {:?}: got {:.4}°, expected {:.4}°",
735 grat_lon_deg,
736 grat_lat_deg,
737 graticule_coord,
738 input_coord,
739 result_lat * RAD2DEG,
740 expected_lat_deg
741 );
742 }
743
744 #[test]
745 fn graticule_celestial_to_celestial_identity() {
746 test_point_transformation(
748 CoordSystem::C,
749 CoordSystem::C,
750 0.0,
751 0.0, 0.0,
753 0.0,
754 0.01,
755 );
756
757 test_point_transformation(
758 CoordSystem::C,
759 CoordSystem::C,
760 0.0,
761 90.0, 0.0,
763 90.0,
764 0.01,
765 );
766
767 test_point_transformation(
768 CoordSystem::C,
769 CoordSystem::C,
770 90.0,
771 0.0, 90.0,
773 0.0,
774 0.01,
775 );
776 }
777
778 #[test]
779 fn graticule_galactic_in_celestial() {
780 test_point_transformation(
784 CoordSystem::C, CoordSystem::G, 0.0,
787 0.0, -93.6,
789 -28.9,
790 0.5,
791 );
792
793 test_point_transformation(
796 CoordSystem::C,
797 CoordSystem::G,
798 0.0,
799 90.0, -167.1,
801 27.1,
802 0.5,
803 );
804
805 test_point_transformation(CoordSystem::C, CoordSystem::G, 90.0, 0.0, -42.0, 48.3, 1.0);
807 }
808
809 #[test]
810 fn graticule_celestial_in_galactic() {
811 test_point_transformation(
814 CoordSystem::G, CoordSystem::C, 0.0,
817 0.0, 96.3,
819 -60.2,
820 0.5,
821 );
822
823 test_point_transformation(
826 CoordSystem::G,
827 CoordSystem::C,
828 0.0,
829 90.0, 122.9,
831 27.1,
832 0.5,
833 );
834 }
835
836 #[test]
837 fn graticule_ecliptic_to_celestial() {
838 test_point_transformation(CoordSystem::C, CoordSystem::E, 0.0, 0.0, 0.0, 0.0, 0.01);
841
842 test_point_transformation(CoordSystem::C, CoordSystem::E, 0.0, 90.0, -90.0, 66.6, 1.0);
845
846 test_point_transformation(CoordSystem::C, CoordSystem::E, 90.0, 0.0, 90.0, 23.4, 0.5);
848 }
849
850 #[test]
851 fn lonlat_vec_roundtrip() {
852 let test_points = vec![
854 (0.0, 0.0), (PI / 2.0, 0.0), (PI, 0.0), (0.0, PI / 2.0), (0.0, -PI / 2.0), (0.5, 0.3), ];
861
862 for (lon, lat) in test_points {
863 let v = lonlat_to_vec(lon, lat);
864 let (lon2, lat2) = vec_to_lonlat(v);
865
866 let lon_norm = lon.rem_euclid(2.0 * PI);
868 let lon2_norm = lon2.rem_euclid(2.0 * PI);
869
870 assert!(
871 (lon_norm - lon2_norm).abs() < 1e-10
872 || (lon_norm - lon2_norm).abs() > 2.0 * PI - 1e-10,
873 "Longitude roundtrip failed: {} → {} → {}",
874 lon,
875 lon_norm,
876 lon2_norm
877 );
878 assert!(
879 (lat - lat2).abs() < 1e-10,
880 "Latitude roundtrip failed: {} → {}",
881 lat,
882 lat2
883 );
884 }
885 }
886
887 #[test]
888 fn graticule_transform_with_view_rotation() {
889 use crate::rotation::Rotation;
891
892 let view_rot = Rotation {
894 matrix: [[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
895 };
896
897 let transform = GraticuleTransform::new(CoordSystem::C, CoordSystem::C, Some(view_rot));
898
899 let v = transform.apply(0.0, 0.0);
902 let (lon, lat) = vec_to_lonlat(v);
903
904 assert!(
906 lon > PI / 2.0 * 0.9 && lon < PI / 2.0 * 1.1,
907 "View rotation not applied correctly: got lon = {}",
908 lon * RAD2DEG
909 );
910 assert!(
911 lat.abs() < 0.01,
912 "View rotation shouldn't change latitude much"
913 );
914 }
915
916 #[test]
917 fn test_pole_graticule_no_wrapping() {
918 use crate::mollweide::MollweideProjection;
932 use crate::projection::Projection;
933 use crate::rotation::ViewTransform;
934
935 let proj = MollweideProjection;
936 let transform = GraticuleTransform::new(CoordSystem::E, CoordSystem::G, None);
937 let view = ViewTransform::new(CoordSystem::G, CoordSystem::G, None);
938
939 let lat_extreme = 90.0 * PI / 180.0;
941
942 let mut projected_points = Vec::new();
944 for lon_deg in (0..360).step_by(10) {
945 let lon_ecl = lon_deg as f64 * PI / 180.0;
946
947 let v_final = transform.apply(lon_ecl, lat_extreme);
949 let v_viewed = view.apply(v_final);
950 let (lon_final, lat_final) = vec_to_lonlat(v_viewed);
951
952 if let Some((u, v)) = proj.forward(lon_final, lat_final) {
954 projected_points.push((lon_deg, u, v));
955 }
956 }
957
958 if projected_points.len() > 1 {
961 let first = &projected_points[0];
962
963 for point in &projected_points[1..] {
964 let du = (point.1 - first.1).abs();
967 let dv = (point.2 - first.2).abs();
968
969 assert!(
972 du < 0.15 || dv < 0.15,
973 "Pole wraparound detected: different longitudes at pole gave different projections. \
974 Lon {}°: ({:.4},{:.4}) vs Lon {}°: ({:.4},{:.4})",
975 first.0,
976 first.1,
977 first.2,
978 point.0,
979 point.1,
980 point.2
981 );
982 }
983 }
984
985 println!("✓ Pole graticule wrapping test passed: no boundary crossing at ±90° latitude");
986 }
987}