Skip to main content

map2fig/
graticule.rs

1use crate::rotation::{CoordSystem, Rotation, coord_rotation};
2use std::f64::consts::PI;
3
4/// Generate evenly-spaced graticule lines that always include key lines (0°, ±90°)
5///
6/// For meridians (longitude):
7///   - Always includes: 0° (prime meridian), 90°, 180°, 270°
8///   - Then fills in with spacing_deg intervals from 0°
9///   - All lines are in [0°, 360°) range
10///
11/// For parallels (latitude):
12///   - Always includes: 0° (equator), 90° (north pole), -90° (south pole)
13///   - Then fills in with spacing_deg intervals from 0° toward ±90°
14fn 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        // For latitude: always include poles and equator
19        degrees.push(0.0); // Equator
20        degrees.push(90.0); // North pole
21        degrees.push(-90.0); // South pole
22
23        // Fill with regular spacing from equator toward poles
24        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        // For longitude: always include cardinal meridians
32        degrees.push(0.0); // Prime meridian
33        degrees.push(90.0); // East
34        degrees.push(180.0); // Antimeridian
35        degrees.push(270.0); // West
36
37        // Fill with regular spacing from prime meridian
38        let mut deg = spacing_deg;
39        while deg < 360.0 {
40            degrees.push(deg);
41            deg += spacing_deg;
42        }
43    }
44
45    // Sort and deduplicate (remove near-duplicates from mandatory lines matching spacing)
46    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/// Represents a polyline in normalized Mollweide coordinates `[0,1]`
52#[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/// Represents all graticule lines for vectorized rendering
82#[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
107/// Transform graticule coordinates through coordinate systems and view rotation
108///
109/// Scenario: You have a map in one coordinate system (e.g., Galactic),
110/// but you want to display a graticule in a different system (e.g., Celestial).
111/// This handles: graticule_coord → map_input_coord → view_rotation
112pub struct GraticuleTransform {
113    /// Rotation from graticule coordinate system to map input coordinate system
114    grat_to_input: Rotation,
115    /// Optional view rotation (applied after coordinate transform)
116    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    /// Transform a point from graticule coordinates through all transformations
133    ///
134    /// Returns the final 3D unit vector after:
135    /// 1. Converting (lon, lat) to cartesian in graticule system
136    /// 2. Rotating to input coordinate system
137    /// 3. Applying optional view rotation
138    pub fn apply(&self, lon: f64, lat: f64) -> [f64; 3] {
139        // 1. lon/lat → vector in graticule coord (standard spherical coords)
140        let v = lonlat_to_vec(lon, lat);
141
142        // 2. graticule coord → map input coord
143        let mut v = self.grat_to_input.apply(v);
144
145        // 3. apply view rotation (if any)
146        if let Some(view_rot) = &self.view {
147            v = view_rot.apply(v);
148        }
149
150        v
151    }
152}
153
154/// Convert lon/lat (in radians) to unit 3D cartesian vector
155/// Standard spherical coordinates: lon increases eastward, lat increases northward
156#[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/// Convert unit 3D cartesian vector to lon/lat (in radians)
163/// Returns (lon in [-π, π], lat in [-π/2, π/2])
164#[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]); // atan2 already returns [-π, π]
168    let lat = (v[2] / r).asin();
169    (lon, lat)
170}
171
172/// Estimate the expected magnitude of coordinate change based on parameter step size
173/// and recent segment history.
174///
175/// Uses the analytical derivative approach: if we have 2+ points, estimate
176/// the typical rate of change and use that to detect anomalous jumps.
177fn estimate_max_jump(segment: &[(f64, f64)], _param_step: f64) -> f64 {
178    if segment.len() < 2 {
179        // Can't estimate without history - use conservative default
180        return 0.15;
181    }
182
183    // Use last two points to estimate derivative
184    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    // Expected jump for the same param step - use 3x safety margin
192    // to account for acceleration near features like poles
193    (dist * 3.0).max(0.05) // minimum threshold of 0.05
194}
195
196/// Render graticule lines (meridians and parallels) for a given coordinate system
197///
198/// Projects graticule lines from `grat_coord` onto the Mollweide projection
199/// after applying the view transformation.
200pub fn render_graticule_mollweide(
201    grid: &mut crate::render::raster::RasterGrid,
202    view: &crate::rotation::ViewTransform,
203    dpar_deg: f64, // parallel (latitude) spacing
204    dmer_deg: f64, // meridian (longitude) spacing
205    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    // dpar_deg and dmer_deg used as calculated absolute values below
215
216    // Get properly-spaced meridian and parallel degrees (always includes key lines)
217    let meridian_degrees = generate_graticule_degrees(dmer_deg, false);
218    let parallel_degrees = generate_graticule_degrees(dpar_deg, true);
219
220    // Meridians: constant longitude in graticule coords
221    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        // Sample parallels along this meridian - cover the full range
227        // and handle the full 0-360 range properly
228        for par_deg in (-90..=90).step_by(2) {
229            let lat_grat = par_deg as f64 * PI / 180.0;
230
231            // Transform through all coordinate systems
232            let v_final = transform.apply(lon_grat, lat_grat);
233
234            // Apply view transformation
235            let v_viewed = view.apply(v_final);
236
237            // Convert back to lon/lat in final coordinate system
238            let (lon_final, lat_final) = vec_to_lonlat(v_viewed);
239
240            // Project to Mollweide
241            if let Some((u, v)) = proj.forward(lon_final, lat_final) {
242                // Check for discontinuities using analytical derivative
243                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                    // Estimate expected jump based on previous motion
254                    let param_step = 2.0; // we step by 2 degrees
255                    let max_expected = estimate_max_jump(&current_segment, param_step);
256
257                    // If jump significantly exceeds expected, it's a discontinuity
258                    if jump_dist > max_expected {
259                        // Discontinuity detected - save segment and start new one
260                        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                // Projection failed - save current segment and start a new one
269                if current_segment.len() > 1 {
270                    line_segments.push(current_segment);
271                }
272                current_segment = Vec::new();
273            }
274        }
275
276        // Add final segment if any
277        if current_segment.len() > 1 {
278            line_segments.push(current_segment);
279        }
280
281        // Draw all segments
282        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    // Parallels: constant latitude in graticule coords
290    for &par_deg in &parallel_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        // For poles (lat = ±90°), all longitudes map to the same point
296        // So we only need one point to represent the pole
297        let is_pole = (par_deg - 90.0).abs() < 0.1 || (par_deg + 90.0).abs() < 0.1;
298
299        if is_pole {
300            // For poles, just sample one longitude to get the single point
301            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            // A pole is just a single point, not a line
307            // Skip drawing for poles (they're points, not lines)
308            // Could draw as a marker if needed in future
309        } else {
310            // Sample meridians along this parallel with fine granularity
311            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                // Transform through all coordinate systems
316                let v_final = transform.apply(lon_grat, lat_grat);
317
318                // Apply view transformation
319                let v_viewed = view.apply(v_final);
320
321                // Convert back to lon/lat in final coordinate system
322                let (lon_final, lat_final) = vec_to_lonlat(v_viewed);
323
324                // Project to Mollweide
325                if let Some((u, v)) = proj.forward(lon_final, lat_final) {
326                    // Check for discontinuities using analytical derivative
327                    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                        // Estimate expected jump based on previous motion
338                        let param_step = 0.5; // we step by 0.5 degrees
339                        let max_expected = estimate_max_jump(&current_segment, param_step);
340
341                        // If jump significantly exceeds expected, it's a discontinuity
342                        if jump_dist > max_expected {
343                            // Discontinuity detected - save segment and start new one
344                            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                    // Projection failed - save current segment and start a new one
353                    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            // Add final segment if any
363            if current_segment.len() > 1 {
364                line_segments.push(current_segment);
365            }
366
367            // Draw all segments
368            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
377/// Generic graticule rendering for any projection
378///
379/// This function returns polylines suitable for vector output formats (PDF, SVG, etc.)
380/// Works with any projection implementing the Projection trait.
381fn render_graticule_vectorized_generic<P: crate::projection::Projection>(
382    proj: &P,
383    view: &crate::rotation::ViewTransform,
384    dpar_deg: f64, // parallel (latitude) spacing
385    dmer_deg: f64, // meridian (longitude) spacing
386    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    // Get properly-spaced meridian and parallel degrees (always includes key lines)
394    let meridian_degrees = generate_graticule_degrees(dmer_deg, false);
395    let parallel_degrees = generate_graticule_degrees(dpar_deg, true);
396
397    // Meridians: constant longitude in graticule coords
398    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        // Sample parallels along this meridian
404        for par_deg in (-90..=90).step_by(2) {
405            let lat_grat = par_deg as f64 * PI / 180.0;
406
407            // Transform through all coordinate systems
408            let v_final = transform.apply(lon_grat, lat_grat);
409
410            // Apply view transformation
411            let v_viewed = view.apply(v_final);
412
413            // Convert back to lon/lat in final coordinate system
414            let (lon_final, lat_final) = vec_to_lonlat(v_viewed);
415
416            // Project to Mollweide
417            if let Some((u, v)) = proj.forward(lon_final, lat_final) {
418                // Check for discontinuities using analytical derivative
419                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                    // Estimate expected jump based on previous motion
430                    let param_step = 2.0; // we step by 2 degrees
431                    let max_expected = estimate_max_jump(&current_segment, param_step);
432
433                    // If jump significantly exceeds expected, it's a discontinuity
434                    if jump_dist > max_expected {
435                        // Discontinuity detected - save segment and start new one
436                        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                // Projection failed - save current segment and start a new one
445                if current_segment.len() > 1 {
446                    line_segments.push(current_segment);
447                }
448                current_segment = Vec::new();
449            }
450        }
451
452        // Add final segment if any
453        if current_segment.len() > 1 {
454            line_segments.push(current_segment);
455        }
456
457        // Convert segments to polylines
458        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    // Parallels: constant latitude in graticule coords
468    for &par_deg in &parallel_degrees {
469        let lat_grat = par_deg * PI / 180.0;
470
471        // Check if this is a pole latitude - poles are points, not line segments
472        let is_pole = (par_deg - 90.0).abs() < 0.1 || (par_deg + 90.0).abs() < 0.1;
473
474        if is_pole {
475            // Skip poles entirely in parallels - they're single points, not lines
476            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        // Sample meridians along this parallel with fine granularity
483        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            // Transform through all coordinate systems
488            let v_final = transform.apply(lon_grat, lat_grat);
489
490            // Apply view transformation
491            let v_viewed = view.apply(v_final);
492
493            // Convert back to lon/lat in final coordinate system
494            let (lon_final, lat_final) = vec_to_lonlat(v_viewed);
495
496            // Project to Mollweide
497            if let Some((u, v)) = proj.forward(lon_final, lat_final) {
498                // Check for discontinuities using analytical derivative
499                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                    // Estimate expected jump based on previous motion
510                    let param_step = 0.5; // we step by 0.5 degrees
511                    let max_expected = estimate_max_jump(&current_segment, param_step);
512
513                    // If jump significantly exceeds expected, it's a discontinuity
514                    if jump_dist > max_expected {
515                        // Discontinuity detected - save segment and start new one
516                        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                // Projection failed - save current segment and start a new one
525                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        // Add final segment if any
535        if current_segment.len() > 1 {
536            line_segments.push(current_segment);
537        }
538
539        // Convert segments to polylines
540        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
552/// Generate vectorized graticule lines for Mollweide projection
553///
554/// This is a convenience wrapper around render_graticule_vectorized_generic.
555pub fn render_graticule_mollweide_vectorized(
556    view: &crate::rotation::ViewTransform,
557    dpar_deg: f64, // parallel (latitude) spacing
558    dmer_deg: f64, // meridian (longitude) spacing
559    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
568/// Generate vectorized graticule lines for Hammer-Aitoff projection
569///
570/// This is a convenience wrapper around render_graticule_vectorized_generic.
571pub fn render_graticule_hammer_vectorized(
572    view: &crate::rotation::ViewTransform,
573    dpar_deg: f64, // parallel (latitude) spacing
574    dmer_deg: f64, // meridian (longitude) spacing
575    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
584/// Render vectorized graticule lines to a Cairo context (for PDF output)
585///
586/// The polylines are scaled from normalized `[0,1]` coordinates to the actual
587/// image dimensions and drawn with vector lines.
588pub 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
609/// Render graticule lines with custom color
610pub 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    // Set line properties for graticule
617    cr.set_source_rgb(color.0, color.1, color.2);
618    cr.set_line_width(0.5); // Thin lines
619
620    for polyline in &graticule.polylines {
621        if polyline.is_empty() {
622            continue;
623        }
624
625        // Start the path at the first point
626        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        // Draw line segments to remaining points
632        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    // Stroke all lines at once
640    let _ = cr.stroke();
641}
642
643/// Draw a line between two normalized `[0,1]` points in the grid
644fn 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    // Bresenham line algorithm
663    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])); // Black graticule
675        }
676    }
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682    use crate::rotation::{DEG2RAD, RAD2DEG};
683
684    /// Test three major points in a coordinate system:
685    /// 1. North pole: (any lon, 90°)
686    /// 2. Equatorial prime meridian: (0°, 0°)
687    /// 3. Equatorial 90° meridian: (90°, 0°)
688    ///
689    /// For each test, verify that after coordinate transformation,
690    /// these points map to predictable locations.
691    ///
692    /// Helper: test a graticule point transformation
693    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        // Normalize longitudes to [-π, π] for comparison
707        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        // Celestial→Celestial should be identity
747        test_point_transformation(
748            CoordSystem::C,
749            CoordSystem::C,
750            0.0,
751            0.0, // (0°, 0°) should stay (0°, 0°)
752            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, // North pole should stay at north pole
762            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°) should stay (90°, 0°)
772            90.0,
773            0.0,
774            0.01,
775        );
776    }
777
778    #[test]
779    fn graticule_galactic_in_celestial() {
780        // Place Galactic equatorial points and see where they appear in Celestial coords
781        // Galactic (0°, 0°) → Celestial coordinates
782        // Based on the GAL_TO_EQ matrix: (-93.6°, -28.9°)
783        test_point_transformation(
784            CoordSystem::C, // input (map) coordinates
785            CoordSystem::G, // graticule coordinates
786            0.0,
787            0.0, // Galactic (0°, 0°)
788            -93.6,
789            -28.9,
790            0.5,
791        );
792
793        // Galactic north pole (0°, 90°) → celestial
794        // Should be at (-167.1°, 27.1°) in celestial
795        test_point_transformation(
796            CoordSystem::C,
797            CoordSystem::G,
798            0.0,
799            90.0, // Galactic north pole
800            -167.1,
801            27.1,
802            0.5,
803        );
804
805        // Galactic (90°, 0°) on equator
806        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        // Inverse: Celestial graticule on Galactic map
812        // Celestial (0°, 0°) → Galactic: (96.3°, -60.2°)
813        test_point_transformation(
814            CoordSystem::G, // input (map) coordinates
815            CoordSystem::C, // graticule coordinates
816            0.0,
817            0.0, // Celestial (0°, 0°)
818            96.3,
819            -60.2,
820            0.5,
821        );
822
823        // Celestial north pole (0°, 90°) in galactic coordinates
824        // Should map to (122.9°, 27.1°) in galactic
825        test_point_transformation(
826            CoordSystem::G,
827            CoordSystem::C,
828            0.0,
829            90.0, // Celestial north pole
830            122.9,
831            27.1,
832            0.5,
833        );
834    }
835
836    #[test]
837    fn graticule_ecliptic_to_celestial() {
838        // Ecliptic graticule on Celestial map
839        // Ecliptic (0°, 0°) → Celestial (0.0°, 0.0°)
840        test_point_transformation(CoordSystem::C, CoordSystem::E, 0.0, 0.0, 0.0, 0.0, 0.01);
841
842        // Ecliptic north pole (0°, 90°) → Celestial
843        // Should be at (-90.0°, 66.6°) approximately
844        test_point_transformation(CoordSystem::C, CoordSystem::E, 0.0, 90.0, -90.0, 66.6, 1.0);
845
846        // Ecliptic (90°, 0°) → Celestial (90.0°, 23.4°)
847        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        // Verify lonlat↔vec conversions are consistent
853        let test_points = vec![
854            (0.0, 0.0),       // Prime meridian, equator
855            (PI / 2.0, 0.0),  // 90° lon, equator
856            (PI, 0.0),        // 180° lon, equator
857            (0.0, PI / 2.0),  // North pole
858            (0.0, -PI / 2.0), // South pole
859            (0.5, 0.3),       // Random point
860        ];
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            // Normalize longitudes
867            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        // Test that view rotation is applied after coordinate transformation
890        use crate::rotation::Rotation;
891
892        // Create a 90° rotation around z-axis (pure camera rotation)
893        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        // Point at (0°, 0°) with 90° view rotation
900        // After rotation, (lon, lat) → should rotate around z-axis
901        let v = transform.apply(0.0, 0.0);
902        let (lon, lat) = vec_to_lonlat(v);
903
904        // Should be rotated by ~90°
905        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        // Test that polar latitude lines don't wrap around boundaries
919        // Regression test for GitHub issue: pole graticules crossing over at boundaries
920        //
921        // This test simulates the problematic case:
922        // - Input map: Galactic (G)
923        // - Output view: Celestial (C)
924        // - Graticule: Ecliptic (E)
925        // - Issue: Top/bottom latitude lines were wrapping at boundaries
926        //
927        // The fix: Skip discontinuity checking at poles (±90°) since pole
928        // transformations can be numerically unstable and all longitudes
929        // converge to the same point anyway.
930
931        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        // Sample the extreme parallel (ecliptic latitude = 90°)
940        let lat_extreme = 90.0 * PI / 180.0;
941
942        // Sample multiple longitudes along this parallel
943        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            // Transform E→G
948            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            // Project to Mollweide
953            if let Some((u, v)) = proj.forward(lon_final, lat_final) {
954                projected_points.push((lon_deg, u, v));
955            }
956        }
957
958        // For a pole, all longitudes should project to approximately the same point
959        // (within projection numerical precision)
960        if projected_points.len() > 1 {
961            let first = &projected_points[0];
962
963            for point in &projected_points[1..] {
964                // At extreme poles, u,v coordinates should cluster together
965                // Allow some numerical tolerance (±0.05 in [0,1] normalized space)
966                let du = (point.1 - first.1).abs();
967                let dv = (point.2 - first.2).abs();
968
969                // If not at a pole, points would spread across the domain
970                // Poles should be tightly clustered
971                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}