use crate::rotation::{CoordSystem, Rotation, coord_rotation};
use std::f64::consts::PI;
fn generate_graticule_degrees(spacing_deg: f64, is_latitude: bool) -> Vec<f64> {
let mut degrees: Vec<f64> = Vec::new();
if is_latitude {
degrees.push(0.0); degrees.push(90.0); degrees.push(-90.0);
let mut deg = spacing_deg;
while deg < 90.0 {
degrees.push(deg);
degrees.push(-deg);
deg += spacing_deg;
}
} else {
degrees.push(0.0); degrees.push(90.0); degrees.push(180.0); degrees.push(270.0);
let mut deg = spacing_deg;
while deg < 360.0 {
degrees.push(deg);
deg += spacing_deg;
}
}
degrees.sort_unstable_by(|a: &f64, b: &f64| a.partial_cmp(b).unwrap());
degrees.dedup_by(|a: &mut f64, b: &mut f64| (*a - *b).abs() < 0.01);
degrees
}
#[derive(Clone, Debug)]
pub struct GraticulePolyline {
pub points: Vec<(f64, f64)>,
}
impl Default for GraticulePolyline {
fn default() -> Self {
Self::new()
}
}
impl GraticulePolyline {
pub fn new() -> Self {
Self { points: Vec::new() }
}
pub fn is_empty(&self) -> bool {
self.points.is_empty()
}
pub fn len(&self) -> usize {
self.points.len()
}
pub fn push(&mut self, point: (f64, f64)) {
self.points.push(point);
}
}
#[derive(Clone, Debug)]
pub struct GraticuleLineSegments {
pub polylines: Vec<GraticulePolyline>,
}
impl Default for GraticuleLineSegments {
fn default() -> Self {
Self::new()
}
}
impl GraticuleLineSegments {
pub fn new() -> Self {
Self {
polylines: Vec::new(),
}
}
pub fn add_polyline(&mut self, polyline: GraticulePolyline) {
if !polyline.is_empty() {
self.polylines.push(polyline);
}
}
}
pub struct GraticuleTransform {
grat_to_input: Rotation,
view: Option<Rotation>,
}
impl GraticuleTransform {
pub fn new(
graticule_coord: CoordSystem,
input_coord: CoordSystem,
view: Option<Rotation>,
) -> Self {
let grat_to_input = coord_rotation(graticule_coord, input_coord);
Self {
grat_to_input,
view,
}
}
pub fn apply(&self, lon: f64, lat: f64) -> [f64; 3] {
let v = lonlat_to_vec(lon, lat);
let mut v = self.grat_to_input.apply(v);
if let Some(view_rot) = &self.view {
v = view_rot.apply(v);
}
v
}
}
#[inline]
fn lonlat_to_vec(lon: f64, lat: f64) -> [f64; 3] {
let cos_lat = lat.cos();
[cos_lat * lon.cos(), cos_lat * lon.sin(), lat.sin()]
}
#[inline]
fn vec_to_lonlat(v: [f64; 3]) -> (f64, f64) {
let r = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
let lon = v[1].atan2(v[0]); let lat = (v[2] / r).asin();
(lon, lat)
}
fn estimate_max_jump(segment: &[(f64, f64)], _param_step: f64) -> f64 {
if segment.len() < 2 {
return 0.15;
}
let (u1, v1) = segment[segment.len() - 2];
let (u2, v2) = segment[segment.len() - 1];
let du = (u2 - u1).abs();
let dv = (v2 - v1).abs();
let dist = (du * du + dv * dv).sqrt();
(dist * 3.0).max(0.05) }
pub fn render_graticule_mollweide(
grid: &mut crate::render::raster::RasterGrid,
view: &crate::rotation::ViewTransform,
dpar_deg: f64, dmer_deg: f64, grat_coord: CoordSystem,
input_coord: CoordSystem,
) {
use crate::mollweide::MollweideProjection;
use crate::projection::Projection;
let transform = GraticuleTransform::new(grat_coord, input_coord, None);
let proj = MollweideProjection;
let meridian_degrees = generate_graticule_degrees(dmer_deg, false);
let parallel_degrees = generate_graticule_degrees(dpar_deg, true);
for &mer_deg_start in &meridian_degrees {
let lon_grat = mer_deg_start * PI / 180.0;
let mut line_segments: Vec<Vec<(f64, f64)>> = Vec::new();
let mut current_segment: Vec<(f64, f64)> = Vec::new();
for par_deg in (-90..=90).step_by(2) {
let lat_grat = par_deg as f64 * PI / 180.0;
let v_final = transform.apply(lon_grat, lat_grat);
let v_viewed = view.apply(v_final);
let (lon_final, lat_final) = vec_to_lonlat(v_viewed);
if let Some((u, v)) = proj.forward(lon_final, lat_final) {
if !current_segment.is_empty()
&& let Some(last_point) = current_segment.last()
{
let prev_u = last_point.0;
let prev_v = last_point.1;
let du = (u - prev_u).abs();
let dv = (v - prev_v).abs();
let jump_dist = (du * du + dv * dv).sqrt();
let param_step = 2.0; let max_expected = estimate_max_jump(¤t_segment, param_step);
if jump_dist > max_expected {
if current_segment.len() > 1 {
line_segments.push(current_segment);
}
current_segment = Vec::new();
}
}
current_segment.push((u, v));
} else {
if current_segment.len() > 1 {
line_segments.push(current_segment);
}
current_segment = Vec::new();
}
}
if current_segment.len() > 1 {
line_segments.push(current_segment);
}
for segment in line_segments {
for window in segment.windows(2) {
draw_line_on_grid(grid, window[0].0, window[0].1, window[1].0, window[1].1);
}
}
}
for &par_deg in ¶llel_degrees {
let lat_grat = par_deg * PI / 180.0;
let mut line_segments: Vec<Vec<(f64, f64)>> = Vec::new();
let mut current_segment: Vec<(f64, f64)> = Vec::new();
let is_pole = (par_deg - 90.0).abs() < 0.1 || (par_deg + 90.0).abs() < 0.1;
if is_pole {
let lon_grat = 0.0;
let v_final = transform.apply(lon_grat, lat_grat);
let v_viewed = view.apply(v_final);
let (_lon_final, _lat_final) = vec_to_lonlat(v_viewed);
} else {
let mut mer_deg_float = 0.0;
while mer_deg_float < 360.0 {
let lon_grat = mer_deg_float * PI / 180.0;
let v_final = transform.apply(lon_grat, lat_grat);
let v_viewed = view.apply(v_final);
let (lon_final, lat_final) = vec_to_lonlat(v_viewed);
if let Some((u, v)) = proj.forward(lon_final, lat_final) {
if !current_segment.is_empty()
&& let Some(last_point) = current_segment.last()
{
let prev_u = last_point.0;
let prev_v = last_point.1;
let du = (u - prev_u).abs();
let dv = (v - prev_v).abs();
let jump_dist = (du * du + dv * dv).sqrt();
let param_step = 0.5; let max_expected = estimate_max_jump(¤t_segment, param_step);
if jump_dist > max_expected {
if current_segment.len() > 1 {
line_segments.push(current_segment);
}
current_segment = Vec::new();
}
}
current_segment.push((u, v));
} else {
if current_segment.len() > 1 {
line_segments.push(current_segment);
}
current_segment = Vec::new();
}
mer_deg_float += 0.5;
}
if current_segment.len() > 1 {
line_segments.push(current_segment);
}
for segment in line_segments {
for window in segment.windows(2) {
draw_line_on_grid(grid, window[0].0, window[0].1, window[1].0, window[1].1);
}
}
}
}
}
fn render_graticule_vectorized_generic<P: crate::projection::Projection>(
proj: &P,
view: &crate::rotation::ViewTransform,
dpar_deg: f64, dmer_deg: f64, grat_coord: CoordSystem,
input_coord: CoordSystem,
) -> GraticuleLineSegments {
let transform = GraticuleTransform::new(grat_coord, input_coord, None);
let mut result = GraticuleLineSegments::new();
let meridian_degrees = generate_graticule_degrees(dmer_deg, false);
let parallel_degrees = generate_graticule_degrees(dpar_deg, true);
for &mer_deg_start in &meridian_degrees {
let lon_grat = mer_deg_start * PI / 180.0;
let mut line_segments: Vec<Vec<(f64, f64)>> = Vec::new();
let mut current_segment: Vec<(f64, f64)> = Vec::new();
for par_deg in (-90..=90).step_by(2) {
let lat_grat = par_deg as f64 * PI / 180.0;
let v_final = transform.apply(lon_grat, lat_grat);
let v_viewed = view.apply(v_final);
let (lon_final, lat_final) = vec_to_lonlat(v_viewed);
if let Some((u, v)) = proj.forward(lon_final, lat_final) {
if !current_segment.is_empty()
&& let Some(last_point) = current_segment.last()
{
let prev_u = last_point.0;
let prev_v = last_point.1;
let du = (u - prev_u).abs();
let dv = (v - prev_v).abs();
let jump_dist = (du * du + dv * dv).sqrt();
let param_step = 2.0; let max_expected = estimate_max_jump(¤t_segment, param_step);
if jump_dist > max_expected {
if current_segment.len() > 1 {
line_segments.push(current_segment);
}
current_segment = Vec::new();
}
}
current_segment.push((u, v));
} else {
if current_segment.len() > 1 {
line_segments.push(current_segment);
}
current_segment = Vec::new();
}
}
if current_segment.len() > 1 {
line_segments.push(current_segment);
}
for segment in line_segments {
let mut polyline = GraticulePolyline::new();
for point in segment {
polyline.push(point);
}
result.add_polyline(polyline);
}
}
for &par_deg in ¶llel_degrees {
let lat_grat = par_deg * PI / 180.0;
let is_pole = (par_deg - 90.0).abs() < 0.1 || (par_deg + 90.0).abs() < 0.1;
if is_pole {
continue;
}
let mut line_segments: Vec<Vec<(f64, f64)>> = Vec::new();
let mut current_segment: Vec<(f64, f64)> = Vec::new();
let mut mer_deg_float = 0.0;
while mer_deg_float < 360.0 {
let lon_grat = mer_deg_float * PI / 180.0;
let v_final = transform.apply(lon_grat, lat_grat);
let v_viewed = view.apply(v_final);
let (lon_final, lat_final) = vec_to_lonlat(v_viewed);
if let Some((u, v)) = proj.forward(lon_final, lat_final) {
if !current_segment.is_empty()
&& let Some(last_point) = current_segment.last()
{
let prev_u = last_point.0;
let prev_v = last_point.1;
let du = (u - prev_u).abs();
let dv = (v - prev_v).abs();
let jump_dist = (du * du + dv * dv).sqrt();
let param_step = 0.5; let max_expected = estimate_max_jump(¤t_segment, param_step);
if jump_dist > max_expected {
if current_segment.len() > 1 {
line_segments.push(current_segment);
}
current_segment = Vec::new();
}
}
current_segment.push((u, v));
} else {
if current_segment.len() > 1 {
line_segments.push(current_segment);
}
current_segment = Vec::new();
}
mer_deg_float += 0.5;
}
if current_segment.len() > 1 {
line_segments.push(current_segment);
}
for segment in line_segments {
let mut polyline = GraticulePolyline::new();
for point in segment {
polyline.push(point);
}
result.add_polyline(polyline);
}
}
result
}
pub fn render_graticule_mollweide_vectorized(
view: &crate::rotation::ViewTransform,
dpar_deg: f64, dmer_deg: f64, grat_coord: CoordSystem,
input_coord: CoordSystem,
) -> GraticuleLineSegments {
use crate::mollweide::MollweideProjection;
let proj = MollweideProjection;
render_graticule_vectorized_generic(&proj, view, dpar_deg, dmer_deg, grat_coord, input_coord)
}
pub fn render_graticule_hammer_vectorized(
view: &crate::rotation::ViewTransform,
dpar_deg: f64, dmer_deg: f64, grat_coord: CoordSystem,
input_coord: CoordSystem,
) -> GraticuleLineSegments {
use crate::hammer::HammerProjection;
let proj = HammerProjection::new();
render_graticule_vectorized_generic(&proj, view, dpar_deg, dmer_deg, grat_coord, input_coord)
}
pub fn render_graticule_cairo(
graticule: &GraticuleLineSegments,
cr: &cairo::Context,
x_offset: f64,
y_offset: f64,
width: f64,
height: f64,
) {
render_graticule_cairo_with_color(
graticule,
cr,
crate::params::GeometryRect {
x: x_offset,
y: y_offset,
w: width,
h: height,
},
(0.0, 0.0, 0.0),
);
}
pub fn render_graticule_cairo_with_color(
graticule: &GraticuleLineSegments,
cr: &cairo::Context,
layout: crate::params::GeometryRect,
color: (f64, f64, f64),
) {
cr.set_source_rgb(color.0, color.1, color.2);
cr.set_line_width(0.5);
for polyline in &graticule.polylines {
if polyline.is_empty() {
continue;
}
let first = polyline.points[0];
let x = layout.x + first.0 * layout.w;
let y = layout.y + first.1 * layout.h;
cr.move_to(x, y);
for &(u, v) in &polyline.points[1..] {
let x = layout.x + u * layout.w;
let y = layout.y + v * layout.h;
cr.line_to(x, y);
}
}
let _ = cr.stroke();
}
fn draw_line_on_grid(
grid: &mut crate::render::raster::RasterGrid,
u0: f64,
v0: f64,
u1: f64,
v1: f64,
) {
use image::Rgba;
let (x0, y0) = (
(u0 * (grid.width - 1) as f64) as i32,
(v0 * (grid.height - 1) as f64) as i32,
);
let (x1, y1) = (
(u1 * (grid.width - 1) as f64) as i32,
(v1 * (grid.height - 1) as f64) as i32,
);
let steps = (x1 - x0).abs().max((y1 - y0).abs()) as usize;
if steps == 0 {
return;
}
for i in 0..=steps {
let t = i as f64 / steps as f64;
let px = ((1.0 - t) * x0 as f64 + t * x1 as f64).round() as u32;
let py = ((1.0 - t) * y0 as f64 + t * y1 as f64).round() as u32;
if px < grid.width && py < grid.height {
grid.set_pixel(px, py, Rgba([0, 0, 0, 255])); }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rotation::{DEG2RAD, RAD2DEG};
fn test_point_transformation(
input_coord: CoordSystem,
graticule_coord: CoordSystem,
grat_lon_deg: f64,
grat_lat_deg: f64,
expected_lon_deg: f64,
expected_lat_deg: f64,
tolerance_deg: f64,
) {
let transform = GraticuleTransform::new(graticule_coord, input_coord, None);
let v = transform.apply(grat_lon_deg * DEG2RAD, grat_lat_deg * DEG2RAD);
let (result_lon, result_lat) = vec_to_lonlat(v);
let expected_lon_rad = expected_lon_deg.rem_euclid(360.0) * DEG2RAD;
let result_lon_normalized = if result_lon > PI {
result_lon - 2.0 * PI
} else if result_lon < -PI {
result_lon + 2.0 * PI
} else {
result_lon
};
let lon_diff = (result_lon_normalized - expected_lon_rad).abs();
let lat_diff = (result_lat - expected_lat_deg * DEG2RAD).abs();
let tolerance_rad = tolerance_deg * DEG2RAD;
assert!(
lon_diff < tolerance_rad || (2.0 * PI - lon_diff) < tolerance_rad,
"Longitude mismatch for ({}, {}) in {:?} → {:?}: got {:.4}°, expected {:.4}°",
grat_lon_deg,
grat_lat_deg,
graticule_coord,
input_coord,
result_lon_normalized * RAD2DEG,
expected_lon_deg
);
assert!(
lat_diff < tolerance_rad,
"Latitude mismatch for ({}, {}) in {:?} → {:?}: got {:.4}°, expected {:.4}°",
grat_lon_deg,
grat_lat_deg,
graticule_coord,
input_coord,
result_lat * RAD2DEG,
expected_lat_deg
);
}
#[test]
fn graticule_celestial_to_celestial_identity() {
test_point_transformation(
CoordSystem::C,
CoordSystem::C,
0.0,
0.0, 0.0,
0.0,
0.01,
);
test_point_transformation(
CoordSystem::C,
CoordSystem::C,
0.0,
90.0, 0.0,
90.0,
0.01,
);
test_point_transformation(
CoordSystem::C,
CoordSystem::C,
90.0,
0.0, 90.0,
0.0,
0.01,
);
}
#[test]
fn graticule_galactic_in_celestial() {
test_point_transformation(
CoordSystem::C, CoordSystem::G, 0.0,
0.0, -93.6,
-28.9,
0.5,
);
test_point_transformation(
CoordSystem::C,
CoordSystem::G,
0.0,
90.0, -167.1,
27.1,
0.5,
);
test_point_transformation(CoordSystem::C, CoordSystem::G, 90.0, 0.0, -42.0, 48.3, 1.0);
}
#[test]
fn graticule_celestial_in_galactic() {
test_point_transformation(
CoordSystem::G, CoordSystem::C, 0.0,
0.0, 96.3,
-60.2,
0.5,
);
test_point_transformation(
CoordSystem::G,
CoordSystem::C,
0.0,
90.0, 122.9,
27.1,
0.5,
);
}
#[test]
fn graticule_ecliptic_to_celestial() {
test_point_transformation(CoordSystem::C, CoordSystem::E, 0.0, 0.0, 0.0, 0.0, 0.01);
test_point_transformation(CoordSystem::C, CoordSystem::E, 0.0, 90.0, -90.0, 66.6, 1.0);
test_point_transformation(CoordSystem::C, CoordSystem::E, 90.0, 0.0, 90.0, 23.4, 0.5);
}
#[test]
fn lonlat_vec_roundtrip() {
let test_points = vec![
(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), ];
for (lon, lat) in test_points {
let v = lonlat_to_vec(lon, lat);
let (lon2, lat2) = vec_to_lonlat(v);
let lon_norm = lon.rem_euclid(2.0 * PI);
let lon2_norm = lon2.rem_euclid(2.0 * PI);
assert!(
(lon_norm - lon2_norm).abs() < 1e-10
|| (lon_norm - lon2_norm).abs() > 2.0 * PI - 1e-10,
"Longitude roundtrip failed: {} → {} → {}",
lon,
lon_norm,
lon2_norm
);
assert!(
(lat - lat2).abs() < 1e-10,
"Latitude roundtrip failed: {} → {}",
lat,
lat2
);
}
}
#[test]
fn graticule_transform_with_view_rotation() {
use crate::rotation::Rotation;
let view_rot = Rotation {
matrix: [[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
};
let transform = GraticuleTransform::new(CoordSystem::C, CoordSystem::C, Some(view_rot));
let v = transform.apply(0.0, 0.0);
let (lon, lat) = vec_to_lonlat(v);
assert!(
lon > PI / 2.0 * 0.9 && lon < PI / 2.0 * 1.1,
"View rotation not applied correctly: got lon = {}",
lon * RAD2DEG
);
assert!(
lat.abs() < 0.01,
"View rotation shouldn't change latitude much"
);
}
#[test]
fn test_pole_graticule_no_wrapping() {
use crate::mollweide::MollweideProjection;
use crate::projection::Projection;
use crate::rotation::ViewTransform;
let proj = MollweideProjection;
let transform = GraticuleTransform::new(CoordSystem::E, CoordSystem::G, None);
let view = ViewTransform::new(CoordSystem::G, CoordSystem::G, None);
let lat_extreme = 90.0 * PI / 180.0;
let mut projected_points = Vec::new();
for lon_deg in (0..360).step_by(10) {
let lon_ecl = lon_deg as f64 * PI / 180.0;
let v_final = transform.apply(lon_ecl, lat_extreme);
let v_viewed = view.apply(v_final);
let (lon_final, lat_final) = vec_to_lonlat(v_viewed);
if let Some((u, v)) = proj.forward(lon_final, lat_final) {
projected_points.push((lon_deg, u, v));
}
}
if projected_points.len() > 1 {
let first = &projected_points[0];
for point in &projected_points[1..] {
let du = (point.1 - first.1).abs();
let dv = (point.2 - first.2).abs();
assert!(
du < 0.15 || dv < 0.15,
"Pole wraparound detected: different longitudes at pole gave different projections. \
Lon {}°: ({:.4},{:.4}) vs Lon {}°: ({:.4},{:.4})",
first.0,
first.1,
first.2,
point.0,
point.1,
point.2
);
}
}
println!("✓ Pole graticule wrapping test passed: no boundary crossing at ±90° latitude");
}
}