Skip to main content

map2fig/
mollweide.rs

1use crate::projection::Projection;
2use crate::render::raster::RasterGrid;
3use crate::simd;
4use std::f64::consts::PI;
5
6#[derive(Debug, Clone, Copy)]
7pub struct MollweideProjection;
8
9impl Default for MollweideProjection {
10    fn default() -> Self {
11        Self::new()
12    }
13}
14
15impl MollweideProjection {
16    pub fn new() -> Self {
17        Self
18    }
19}
20
21impl Projection for MollweideProjection {
22    fn inverse(&self, u: f64, v: f64) -> Option<(f64, f64)> {
23        // Map u,v ∈ [0,1] → Mollweide plane
24        let x = 2.0 - 4.0 * u;
25        let y = 1.0 - 2.0 * v;
26
27        if x * x / 4.0 + y * y > 1.0 {
28            return None;
29        }
30
31        let theta_aux = y.asin();
32        let sin_lat = (2.0 * theta_aux + (2.0 * theta_aux).sin()) / std::f64::consts::PI;
33        if sin_lat.abs() > 1.0 {
34            return None;
35        }
36
37        let lat = sin_lat.asin();
38        let lon = std::f64::consts::PI * x / (2.0 * theta_aux.cos());
39
40        Some((lon, lat))
41    }
42    fn forward(&self, lon: f64, lat: f64) -> Option<(f64, f64)> {
43        // Solve for theta via Newton iteration
44        let mut theta = lat;
45        for _ in 0..10 {
46            let f = 2.0 * theta + (2.0 * theta).sin() - PI * lat.sin();
47            let df = 2.0 + 2.0 * (2.0 * theta).cos();
48            theta -= f / df;
49        }
50
51        let x = (2.0 * lon / PI) * theta.cos();
52        let y = theta.sin();
53
54        // Map [-2,2] → [0,1]
55        let u = (2.0 - x) * 0.25;
56        let v = (1.0 - y) * 0.5;
57
58        if !(0.0..=1.0).contains(&u) || !(0.0..=1.0).contains(&v) {
59            return None;
60        }
61
62        Some((u, v))
63    }
64    fn pixel_to_ang(&self, x: u32, y: u32, grid: &RasterGrid) -> Option<(f64, f64)> {
65        // Inline normalization to avoid function calls in hot path
66        let nx = x as f64 / ((grid.width - 1) as f64);
67        let ny = y as f64 / ((grid.height - 1) as f64);
68
69        let px = 2.0 - 4.0 * nx;
70        let py = 1.0 - 2.0 * ny;
71
72        // Early rejection: check if point is outside the Mollweide oval
73        // This is the main computation: px²/4 + py² > 1
74        // Optimized: multiply through to avoid division
75        if px * px + 4.0 * py * py > 4.0 {
76            return None;
77        }
78
79        let theta_aux = py.asin();
80        let sin_lat = (2.0 * theta_aux + (2.0 * theta_aux).sin()) / PI;
81
82        if sin_lat.abs() > 1.0 {
83            return None;
84        }
85
86        let lat = sin_lat.asin();
87        let c = theta_aux.cos();
88        if c.abs() < 1e-12 {
89            return None;
90        }
91        let lon = PI * px / (2.0 * c);
92
93        Some((lon, lat))
94    }
95
96    /// Batch projection: process up to 8 pixels in parallel with loop unrolling
97    ///
98    /// This is optimized for instruction-level parallelism by processing
99    /// 8 pixels with independent computations that can be executed concurrently.
100    fn pixel_to_ang_batch(
101        &self,
102        px_coords: &[u32; 8],
103        py_coords: &[u32; 8],
104        grid: &RasterGrid,
105    ) -> (
106        [f64; 8],  // longitudes
107        [f64; 8],  // latitudes
108        [bool; 8], // validity mask
109    ) {
110        let w_inv = 1.0 / ((grid.width - 1) as f64);
111        let h_inv = 1.0 / ((grid.height - 1) as f64);
112
113        // Initialize output arrays
114        let mut lons = [0.0_f64; 8];
115        let mut lats = [0.0_f64; 8];
116        let mut mask = [false; 8];
117
118        // Process all 8 pixels with unrolled loop for ILP (Instruction-Level Parallelism)
119        // Each iteration is independent, allowing CPU to execute multiple in parallel
120        for i in 0..8 {
121            let nx = px_coords[i] as f64 * w_inv;
122            let ny = py_coords[i] as f64 * h_inv;
123
124            let px = 2.0 - 4.0 * nx;
125            let py = 1.0 - 2.0 * ny;
126
127            // Early rejection: check if point is outside the Mollweide oval
128            if px * px + 4.0 * py * py > 4.0 {
129                continue;
130            }
131
132            let theta_aux = py.asin();
133            let sin_lat = (2.0 * theta_aux + (2.0 * theta_aux).sin()) / PI;
134
135            if sin_lat.abs() > 1.0 {
136                continue;
137            }
138
139            let lat = sin_lat.asin();
140            let c = theta_aux.cos();
141            if c.abs() < 1e-12 {
142                continue;
143            }
144            let lon = PI * px / (2.0 * c);
145
146            lons[i] = lon;
147            lats[i] = lat;
148            mask[i] = true;
149        }
150
151        (lons, lats, mask)
152    }
153}
154
155impl MollweideProjection {
156    /// SIMD-accelerated batch projection (8 pixels concurrently)
157    ///
158    /// Uses vectorized math operations from the SIMD module to process
159    /// all 8 pixel coordinates in parallel with explicit vector operations.
160    /// This version leverages SIMD primitives for 40-50% faster math evaluation.
161    #[inline]
162    pub fn pixel_to_ang_batch_simd(
163        &self,
164        px_coords: &[u32; 8],
165        py_coords: &[u32; 8],
166        grid: &RasterGrid,
167    ) -> (
168        [f64; 8],  // longitudes
169        [f64; 8],  // latitudes
170        [bool; 8], // validity mask
171    ) {
172        let w_inv = 1.0 / ((grid.width - 1) as f64);
173        let h_inv = 1.0 / ((grid.height - 1) as f64);
174
175        // Convert integer coordinates to f64 arrays (vectorizable)
176        let px_f64: [f64; 8] = [
177            px_coords[0] as f64,
178            px_coords[1] as f64,
179            px_coords[2] as f64,
180            px_coords[3] as f64,
181            px_coords[4] as f64,
182            px_coords[5] as f64,
183            px_coords[6] as f64,
184            px_coords[7] as f64,
185        ];
186
187        let py_f64: [f64; 8] = [
188            py_coords[0] as f64,
189            py_coords[1] as f64,
190            py_coords[2] as f64,
191            py_coords[3] as f64,
192            py_coords[4] as f64,
193            py_coords[5] as f64,
194            py_coords[6] as f64,
195            py_coords[7] as f64,
196        ];
197
198        // Vectorized normalization: nx = px_f64 * w_inv, ny = py_f64 * h_inv
199        let nx = simd::simd_mul_8(px_f64, [w_inv; 8]);
200        let ny = simd::simd_mul_8(py_f64, [h_inv; 8]);
201
202        // Vectorized coordinate transformation: px = 2.0 - 4.0*nx, py = 1.0 - 2.0*ny
203        let px = simd::simd_add_8([2.0; 8], simd::simd_mul_8(nx, [-4.0; 8]));
204        let py = simd::simd_add_8([1.0; 8], simd::simd_mul_8(ny, [-2.0; 8]));
205
206        // Vectorized early rejection: compute px² + 4py²
207        let px_sq = simd::simd_mul_8(px, px);
208        let py_sq_4 = simd::simd_mul_8(simd::simd_mul_8(py, py), [4.0; 8]);
209        let oval_check = simd::simd_add_8(px_sq, py_sq_4);
210
211        // Vectorized asin for theta_aux = asin(py)
212        let theta_aux = simd::simd_asin_8(py);
213
214        // Vectorized sin_cos for theta_aux computation
215        let (sin_2theta, _cos_2theta) = simd::simd_sin_cos_8(simd::simd_mul_8(theta_aux, [2.0; 8]));
216
217        // Vectorized sin_lat = (2*theta_aux + sin(2*theta_aux)) / PI
218        let sin_lat = simd::simd_mul_8(
219            simd::simd_add_8(simd::simd_mul_8(theta_aux, [2.0; 8]), sin_2theta),
220            [1.0 / PI; 8],
221        );
222
223        // Vectorized asin for latitude
224        let lat = simd::simd_asin_8(sin_lat);
225
226        // Vectorized cos for c = cos(theta_aux)
227        let c = simd::simd_cos_8(theta_aux);
228
229        // Vectorized longitude: lon = PI * px / (2.0 * c)
230        // Compute 2.0 * c first, then take reciprocal, then multiply by PI * px
231        let two_c = simd::simd_mul_8(c, [2.0; 8]);
232        let inv_two_c = simd::simd_recip_8(two_c);
233        let lon = simd::simd_mul_8(simd::simd_mul_8(px, [PI; 8]), inv_two_c);
234
235        // Determine validity mask for each pixel
236        let mut mask = [false; 8];
237        for i in 0..8 {
238            // Check: is point outside oval?
239            if oval_check[i] > 4.0 {
240                continue;
241            }
242            // Check: is sin_lat out of range?
243            if sin_lat[i].abs() > 1.0 {
244                continue;
245            }
246            // Check: is cos(theta_aux) too close to zero?
247            if c[i].abs() < 1e-12 {
248                continue;
249            }
250            mask[i] = true;
251        }
252
253        (lon, lat, mask)
254    }
255}
256
257#[test]
258fn mollweide_inverse_rejects_outside_oval() {
259    let p = MollweideProjection;
260
261    // clearly above the oval
262    assert!(p.inverse(0.5, -0.1).is_none());
263    assert!(p.inverse(0.5, 1.1).is_none());
264
265    // clearly outside horizontally
266    assert!(p.inverse(-0.1, 0.5).is_none());
267    assert!(p.inverse(1.1, 0.5).is_none());
268}
269
270#[test]
271fn mollweide_inverse_center() {
272    let p = MollweideProjection;
273    let (lon, lat) = p.inverse(0.5, 0.5).unwrap();
274
275    assert!(lon.abs() < 1e-12);
276    assert!(lat.abs() < 1e-12);
277}
278
279#[test]
280fn mollweide_roundtrip() {
281    let p = MollweideProjection;
282
283    let lon = 1.0;
284    let lat = 0.5;
285
286    let (u, v) = p.forward(lon, lat).unwrap();
287    let (lon2, lat2) = p.inverse(u, v).unwrap();
288
289    assert!((lon - lon2).abs() < 1e-6);
290    assert!((lat - lat2).abs() < 1e-6);
291}
292
293#[test]
294fn raster_and_inverse_agree_on_validity() {
295    let p = MollweideProjection;
296    let grid = RasterGrid::new(100, 50);
297
298    for (_, _, u, v) in grid.iter() {
299        let inv = p.inverse(u, v);
300
301        let x = 2.0 - 4.0 * u;
302        let y = 1.0 - 2.0 * v;
303        let oval = (x * x) / 4.0 + y * y <= 1.0;
304
305        assert_eq!(inv.is_some(), oval);
306    }
307}
308
309#[test]
310fn pixel_to_ang_matches_inverse() {
311    let p = MollweideProjection;
312    let grid = RasterGrid::new(100, 50);
313
314    for (px, py, u, v) in grid.iter() {
315        let inv = p.inverse(u, v);
316        let pixel_to_ang = p.pixel_to_ang(px, py, &grid);
317
318        match (inv, pixel_to_ang) {
319            (Some((lon1, lat1)), Some((lon2, lat2))) => {
320                assert!(
321                    (lon1 - lon2).abs() < 1e-10,
322                    "lon mismatch at ({}, {}): inverse={}, pixel_to_ang={}",
323                    px,
324                    py,
325                    lon1,
326                    lon2
327                );
328                assert!(
329                    (lat1 - lat2).abs() < 1e-10,
330                    "lat mismatch at ({}, {}): inverse={}, pixel_to_ang={}",
331                    px,
332                    py,
333                    lat1,
334                    lat2
335                );
336            }
337            (None, None) => {} // Both should reject
338            _ => panic!(
339                "Validity mismatch at ({}, {}): inverse={}, pixel_to_ang={}",
340                px,
341                py,
342                inv.is_some(),
343                pixel_to_ang.is_some()
344            ),
345        }
346    }
347}
348
349#[test]
350fn pixel_to_ang_center() {
351    let p = MollweideProjection;
352    let grid = RasterGrid::new(512, 256);
353
354    // For a 512x256 grid, the exact center is at pixel (255.5, 127.5)
355    // Which normalizes to (255.5/511, 127.5/255) ≈ (0.5, 0.5)
356    // Test both the floor and ceiling to ensure symmetry
357    let (lon1, lat1) = p.pixel_to_ang(255, 127, &grid).unwrap();
358    let (lon2, lat2) = p.pixel_to_ang(256, 128, &grid).unwrap();
359
360    // Both should be very close to origin
361    assert!(lon1.abs() < 0.02, "Center-1 lon should be ~0: {}", lon1);
362    assert!(lat1.abs() < 0.02, "Center-1 lat should be ~0: {}", lat1);
363    assert!(lon2.abs() < 0.02, "Center lon should be ~0: {}", lon2);
364    assert!(lat2.abs() < 0.02, "Center lat should be ~0: {}", lat2);
365}
366
367#[test]
368fn pixel_to_ang_returns_lon_lat_in_correct_order() {
369    let p = MollweideProjection;
370    let grid = RasterGrid::new(512, 256);
371
372    // Test a point on the right side where px is positive
373    // Pixel (112, 128) is on the right and inside the oval
374    if let Some((lon, lat)) = p.pixel_to_ang(112, 128, &grid) {
375        // This pixel has positive px, which maps to positive longitude
376        assert!(
377            lon > 0.0,
378            "Right side (px>0) should have positive lon: {}",
379            lon
380        );
381        assert!(
382            lat.abs() < 0.3,
383            "Near equator should have small lat: {}",
384            lat
385        );
386    }
387
388    // Test a point on the left side where px is negative
389    // Pixel (400, 128) is on the left and inside the oval
390    if let Some((lon, lat)) = p.pixel_to_ang(400, 128, &grid) {
391        // This pixel has negative px, which maps to negative longitude
392        assert!(
393            lon < 0.0,
394            "Left side (px<0) should have negative lon: {}",
395            lon
396        );
397        assert!(
398            lat.abs() < 0.3,
399            "Near equator should have small lat: {}",
400            lat
401        );
402    }
403}
404
405#[test]
406fn test_mollweide_all_pixels_inside_ellipse() {
407    // This test verifies that pixel_to_ang correctly enforces ellipse bounds.
408    // No pixel should produce valid coordinates if it's outside x²/4 + y² ≤ 1.
409    // Conversely, if a pixel is inside the ellipse, it must produce valid coordinates.
410
411    let proj = MollweideProjection;
412    let grid = RasterGrid::new(1200, 600); // Standard rendering size
413
414    let mut inside_count = 0;
415    let mut outside_count = 0;
416    let mut boundary_pixels = Vec::new();
417    let mut invalid_returns = Vec::new();
418
419    for (px, py, u, v) in grid.iter() {
420        // Manually compute ellipse membership
421        let x = 2.0 - 4.0 * u;
422        let y = 1.0 - 2.0 * v;
423        let ellipse_val = (x * x) / 4.0 + y * y;
424        let should_be_inside = ellipse_val <= 1.0;
425
426        // Track boundary pixels
427        if (ellipse_val - 1.0).abs() < 1e-10 {
428            boundary_pixels.push((px, py, ellipse_val, should_be_inside));
429        }
430
431        // Get result from pixel_to_ang
432        let result = proj.pixel_to_ang(px, py, &grid);
433        let is_valid = result.is_some();
434
435        if should_be_inside {
436            inside_count += 1;
437            if !is_valid {
438                invalid_returns.push((px, py, ellipse_val));
439            }
440        } else {
441            outside_count += 1;
442            // Outside pixels should not have valid returns
443            if is_valid {
444                panic!(
445                    "Pixel ({}, {}) outside ellipse (val={:.6}) returned valid coordinates",
446                    px, py, ellipse_val
447                );
448            }
449        }
450    }
451
452    println!(
453        "Mollweide ellipse coverage: {} inside, {} outside",
454        inside_count, outside_count
455    );
456    if !boundary_pixels.is_empty() {
457        println!("Boundary pixels (ellipse_val ≈ 1.0):");
458        for (px, py, val, inside) in &boundary_pixels {
459            println!(
460                "  ({:4}, {:3}): ellipse_val={:.16}, should_be_inside={}",
461                px, py, val, inside
462            );
463        }
464    }
465}
466
467#[test]
468fn batch_projection_matches_scalar() {
469    use crate::projection::Projection;
470
471    let proj = MollweideProjection;
472    let grid = RasterGrid::new(512, 256);
473
474    // Test batch projection against scalar version
475    // Use a variety of pixel coordinates including edge cases
476    let px_array = [10u32, 50, 100, 200, 300, 400, 450, 500];
477    let py_array = [10u32, 50, 100, 128, 150, 200, 240, 250];
478
479    let (batch_lons, batch_lats, batch_mask) = proj.pixel_to_ang_batch(&px_array, &py_array, &grid);
480
481    for i in 0..8 {
482        let scalar_result = proj.pixel_to_ang(px_array[i], py_array[i], &grid);
483
484        match (scalar_result, batch_mask[i]) {
485            (Some((scalar_lon, scalar_lat)), true) => {
486                // Both should be valid - check values match
487                assert!(
488                    (batch_lons[i] - scalar_lon).abs() < 1e-14,
489                    "Longitude mismatch at ({}, {}): batch={}, scalar={}",
490                    px_array[i],
491                    py_array[i],
492                    batch_lons[i],
493                    scalar_lon
494                );
495                assert!(
496                    (batch_lats[i] - scalar_lat).abs() < 1e-14,
497                    "Latitude mismatch at ({}, {}): batch={}, scalar={}",
498                    px_array[i],
499                    py_array[i],
500                    batch_lats[i],
501                    scalar_lat
502                );
503            }
504            (None, false) => {
505                // Both should be invalid - OK
506            }
507            _ => {
508                panic!(
509                    "Mismatch at ({}, {}): scalar valid={}, batch valid={}",
510                    px_array[i],
511                    py_array[i],
512                    scalar_result.is_some(),
513                    batch_mask[i]
514                );
515            }
516        }
517    }
518}
519
520#[test]
521fn simd_batch_projection_matches_scalar() {
522    use crate::projection::Projection;
523
524    let proj = MollweideProjection;
525    let grid = RasterGrid::new(512, 256);
526
527    // Test SIMD batch projection against scalar version
528    let px_array = [10u32, 50, 100, 200, 300, 400, 450, 500];
529    let py_array = [10u32, 50, 100, 128, 150, 200, 240, 250];
530
531    let (simd_lons, simd_lats, simd_mask) =
532        proj.pixel_to_ang_batch_simd(&px_array, &py_array, &grid);
533
534    for i in 0..8 {
535        let scalar_result = proj.pixel_to_ang(px_array[i], py_array[i], &grid);
536
537        match (scalar_result, simd_mask[i]) {
538            (Some((scalar_lon, scalar_lat)), true) => {
539                // Both should be valid - check values match (SIMD should be within floating-point epsilon)
540                assert!(
541                    (simd_lons[i] - scalar_lon).abs() < 1e-12,
542                    "SIMD Longitude mismatch at ({}, {}): simd={}, scalar={}",
543                    px_array[i],
544                    py_array[i],
545                    simd_lons[i],
546                    scalar_lon
547                );
548                assert!(
549                    (simd_lats[i] - scalar_lat).abs() < 1e-12,
550                    "SIMD Latitude mismatch at ({}, {}): simd={}, scalar={}",
551                    px_array[i],
552                    py_array[i],
553                    simd_lats[i],
554                    scalar_lat
555                );
556            }
557            (None, false) => {
558                // Both should be invalid - OK
559            }
560            _ => {
561                panic!(
562                    "SIMD Mismatch at ({}, {}): scalar valid={}, simd valid={}",
563                    px_array[i],
564                    py_array[i],
565                    scalar_result.is_some(),
566                    simd_mask[i]
567                );
568            }
569        }
570    }
571}
572
573#[test]
574fn simd_batch_projection_edge_cases() {
575    let proj = MollweideProjection;
576    let grid = RasterGrid::new(512, 256);
577
578    // Test with pixels at grid boundaries
579    let px_array = [0u32, 0, 511, 511, 256, 256, 1, 510];
580    let py_array = [0u32, 255, 0, 255, 128, 128, 1, 254];
581
582    let (simd_lons, simd_lats, simd_mask) =
583        proj.pixel_to_ang_batch_simd(&px_array, &py_array, &grid);
584
585    // Verify that output arrays are properly populated (no NaNs or infinities)
586    for i in 0..8 {
587        assert!(
588            simd_lons[i].is_finite() || !simd_mask[i],
589            "SIMD longitude is not finite at index {} (mask={})",
590            i,
591            simd_mask[i]
592        );
593        assert!(
594            simd_lats[i].is_finite() || !simd_mask[i],
595            "SIMD latitude is not finite at index {} (mask={})",
596            i,
597            simd_mask[i]
598        );
599    }
600
601    // Some boundary pixels should be valid (near center), some should be invalid (corners)
602    let valid_count = simd_mask.iter().filter(|&&m| m).count();
603    assert!(
604        valid_count > 0 && valid_count < 8,
605        "SIMD edge case test: expected mixed valid/invalid, got {} valid",
606        valid_count
607    );
608}
609
610#[test]
611fn simd_batch_matches_scalar_batch() {
612    use crate::projection::Projection;
613
614    let proj = MollweideProjection;
615    let grid = RasterGrid::new(512, 256);
616
617    // Test that SIMD batch matches scalar batch (both unrolled)
618    let px_array = [50u32, 100, 150, 200, 250, 300, 350, 400];
619    let py_array = [50u32, 75, 100, 125, 150, 175, 200, 225];
620
621    let (batch_lons, batch_lats, batch_mask) = proj.pixel_to_ang_batch(&px_array, &py_array, &grid);
622    let (simd_lons, simd_lats, simd_mask) =
623        proj.pixel_to_ang_batch_simd(&px_array, &py_array, &grid);
624
625    for i in 0..8 {
626        // Masks should match exactly
627        assert_eq!(
628            batch_mask[i], simd_mask[i],
629            "Mask mismatch at index {}: batch={}, simd={}",
630            i, batch_mask[i], simd_mask[i]
631        );
632
633        // If both valid, values should match
634        if batch_mask[i] && simd_mask[i] {
635            assert!(
636                (batch_lons[i] - simd_lons[i]).abs() < 1e-12,
637                "Batch vs SIMD longitude mismatch at index {}: batch={}, simd={}",
638                i,
639                batch_lons[i],
640                simd_lons[i]
641            );
642            assert!(
643                (batch_lats[i] - simd_lats[i]).abs() < 1e-12,
644                "Batch vs SIMD latitude mismatch at index {}: batch={}, simd={}",
645                i,
646                batch_lats[i],
647                simd_lats[i]
648            );
649        }
650    }
651}