eulumdat_photweb/
photweb.rs

1//! PhotometricWeb - Core representation of a luminous intensity distribution
2
3use eulumdat::{Eulumdat, Symmetry};
4
5/// A photometric web representing the full 3D luminous intensity distribution.
6///
7/// This structure provides efficient sampling of intensity values at any
8/// C-plane and gamma angle, handling symmetry automatically.
9#[derive(Debug, Clone)]
10pub struct PhotometricWeb {
11    /// C-plane angles in degrees (0-360)
12    c_angles: Vec<f64>,
13    /// Gamma angles in degrees (0-180)
14    g_angles: Vec<f64>,
15    /// Intensity values in cd/klm, indexed as `[c_index][g_index]`
16    intensities: Vec<Vec<f64>>,
17    /// Symmetry type
18    symmetry: Symmetry,
19    /// Maximum intensity value (cached)
20    max_intensity: f64,
21    /// Minimum intensity value (cached)
22    min_intensity: f64,
23}
24
25impl PhotometricWeb {
26    /// Create a new PhotometricWeb from raw data.
27    pub fn new(
28        c_angles: Vec<f64>,
29        g_angles: Vec<f64>,
30        intensities: Vec<Vec<f64>>,
31        symmetry: Symmetry,
32    ) -> Self {
33        let max_intensity = intensities
34            .iter()
35            .flat_map(|row| row.iter())
36            .copied()
37            .fold(0.0, f64::max);
38        let min_intensity = intensities
39            .iter()
40            .flat_map(|row| row.iter())
41            .copied()
42            .fold(f64::MAX, f64::min);
43
44        Self {
45            c_angles,
46            g_angles,
47            intensities,
48            symmetry,
49            max_intensity,
50            min_intensity,
51        }
52    }
53
54    /// Sample intensity at any C and G angle using bilinear interpolation.
55    ///
56    /// Handles symmetry automatically - you can query any angle in the full
57    /// 0-360° C range and 0-180° G range regardless of stored symmetry.
58    ///
59    /// # Arguments
60    /// * `c_angle` - C-plane angle in degrees (will be normalized to 0-360)
61    /// * `g_angle` - Gamma angle in degrees (will be clamped to 0-180)
62    ///
63    /// # Returns
64    /// Intensity in cd/klm
65    pub fn sample(&self, c_angle: f64, g_angle: f64) -> f64 {
66        // Normalize C angle to 0-360 range
67        let c_normalized = c_angle.rem_euclid(360.0);
68        // Clamp G angle to 0-180 range
69        let g_clamped = g_angle.clamp(0.0, 180.0);
70
71        // Find the effective C based on symmetry
72        let effective_c = self.apply_symmetry(c_normalized);
73
74        // Find interpolation indices
75        let (ci, cf) = self.find_interpolation_index(&self.c_angles, effective_c);
76        let (gi, gf) = self.find_interpolation_index(&self.g_angles, g_clamped);
77
78        // Bilinear interpolation
79        self.bilinear_interpolate(ci, cf, gi, gf)
80    }
81
82    /// Sample normalized intensity (0.0 to 1.0) at any C and G angle.
83    ///
84    /// This is useful for mesh generation where you want distances scaled
85    /// relative to the maximum intensity.
86    pub fn sample_normalized(&self, c_angle: f64, g_angle: f64) -> f64 {
87        if self.max_intensity <= 0.0 {
88            return 0.0;
89        }
90        self.sample(c_angle, g_angle) / self.max_intensity
91    }
92
93    /// Get the maximum intensity value.
94    pub fn max_intensity(&self) -> f64 {
95        self.max_intensity
96    }
97
98    /// Get the minimum intensity value.
99    pub fn min_intensity(&self) -> f64 {
100        self.min_intensity
101    }
102
103    /// Get the symmetry type.
104    pub fn symmetry(&self) -> Symmetry {
105        self.symmetry
106    }
107
108    /// Get the stored C-plane angles.
109    pub fn c_angles(&self) -> &[f64] {
110        &self.c_angles
111    }
112
113    /// Get the stored gamma angles.
114    pub fn g_angles(&self) -> &[f64] {
115        &self.g_angles
116    }
117
118    /// Apply symmetry to map any C angle to the stored range.
119    fn apply_symmetry(&self, c_normalized: f64) -> f64 {
120        match self.symmetry {
121            Symmetry::None => c_normalized,
122            Symmetry::VerticalAxis => 0.0, // All C-planes are the same
123            Symmetry::PlaneC0C180 => {
124                if c_normalized <= 180.0 {
125                    c_normalized
126                } else {
127                    360.0 - c_normalized
128                }
129            }
130            Symmetry::PlaneC90C270 => {
131                // Mirror across the C90-C270 line (left-right symmetry)
132                // Data stored for C0-C180 (right half)
133                // C180-C360 mirrors to C180-C0
134                if c_normalized > 180.0 {
135                    360.0 - c_normalized // C225→C135, C270→C90, C315→C45
136                } else {
137                    c_normalized // C0-C180 stored as-is
138                }
139            }
140            Symmetry::BothPlanes => {
141                let in_first_half = c_normalized <= 180.0;
142                let c_in_half = if in_first_half {
143                    c_normalized
144                } else {
145                    360.0 - c_normalized
146                };
147                if c_in_half <= 90.0 {
148                    c_in_half
149                } else {
150                    180.0 - c_in_half
151                }
152            }
153        }
154    }
155
156    /// Find interpolation index and fraction for a target angle.
157    fn find_interpolation_index(&self, angles: &[f64], target: f64) -> (usize, f64) {
158        if angles.is_empty() {
159            return (0, 0.0);
160        }
161
162        if target <= angles[0] {
163            return (0, 0.0);
164        }
165
166        if target >= angles[angles.len() - 1] {
167            return (angles.len() - 1, 0.0);
168        }
169
170        for i in 0..angles.len() - 1 {
171            if target >= angles[i] && target <= angles[i + 1] {
172                let fraction = (target - angles[i]) / (angles[i + 1] - angles[i]);
173                return (i, fraction);
174            }
175        }
176
177        (angles.len() - 1, 0.0)
178    }
179
180    /// Perform bilinear interpolation.
181    fn bilinear_interpolate(&self, ci: usize, cf: f64, gi: usize, gf: f64) -> f64 {
182        let get = |c: usize, g: usize| -> f64 {
183            self.intensities
184                .get(c)
185                .and_then(|row| row.get(g))
186                .copied()
187                .unwrap_or(0.0)
188        };
189
190        let i00 = get(ci, gi);
191        let i01 = get(ci, gi + 1);
192        let i10 = get(ci + 1, gi);
193        let i11 = get(ci + 1, gi + 1);
194
195        // Bilinear interpolation
196        let i0 = i00 * (1.0 - gf) + i01 * gf;
197        let i1 = i10 * (1.0 - gf) + i11 * gf;
198
199        i0 * (1.0 - cf) + i1 * cf
200    }
201}
202
203impl From<&Eulumdat> for PhotometricWeb {
204    fn from(ldt: &Eulumdat) -> Self {
205        Self::new(
206            ldt.c_angles.clone(),
207            ldt.g_angles.clone(),
208            ldt.intensities.clone(),
209            ldt.symmetry,
210        )
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    fn create_test_web() -> PhotometricWeb {
219        PhotometricWeb::new(
220            vec![0.0, 90.0, 180.0, 270.0],
221            vec![0.0, 45.0, 90.0, 135.0, 180.0],
222            vec![
223                vec![100.0, 80.0, 50.0, 30.0, 10.0], // C0
224                vec![90.0, 70.0, 40.0, 25.0, 8.0],   // C90
225                vec![80.0, 60.0, 30.0, 20.0, 5.0],   // C180
226                vec![85.0, 65.0, 35.0, 22.0, 6.0],   // C270
227            ],
228            Symmetry::None,
229        )
230    }
231
232    #[test]
233    fn test_sample_exact_angles() {
234        let web = create_test_web();
235
236        // Test exact stored angles
237        let i = web.sample(0.0, 0.0);
238        assert!((i - 100.0).abs() < 0.001);
239
240        let i = web.sample(90.0, 45.0);
241        assert!((i - 70.0).abs() < 0.001);
242
243        let i = web.sample(180.0, 90.0);
244        assert!((i - 30.0).abs() < 0.001);
245    }
246
247    #[test]
248    fn test_sample_interpolated() {
249        let web = PhotometricWeb::new(
250            vec![0.0, 90.0],
251            vec![0.0, 90.0],
252            vec![
253                vec![100.0, 0.0], // C0
254                vec![100.0, 0.0], // C90
255            ],
256            Symmetry::None,
257        );
258
259        // Midpoint should be 50
260        let i = web.sample(0.0, 45.0);
261        assert!((i - 50.0).abs() < 0.001);
262    }
263
264    #[test]
265    fn test_sample_normalized() {
266        let web = create_test_web();
267
268        // Max intensity is 100, so normalized at (0, 0) should be 1.0
269        let n = web.sample_normalized(0.0, 0.0);
270        assert!((n - 1.0).abs() < 0.001);
271
272        // Half intensity should be 0.5
273        let n = web.sample_normalized(0.0, 90.0);
274        assert!((n - 0.5).abs() < 0.001);
275    }
276
277    #[test]
278    fn test_symmetry_both_planes() {
279        let web = PhotometricWeb::new(
280            vec![0.0, 45.0, 90.0],
281            vec![0.0, 45.0, 90.0],
282            vec![
283                vec![100.0, 80.0, 50.0], // C0
284                vec![90.0, 70.0, 40.0],  // C45
285                vec![80.0, 60.0, 30.0],  // C90
286            ],
287            Symmetry::BothPlanes,
288        );
289
290        // C180 should mirror C0
291        let i_c0 = web.sample(0.0, 45.0);
292        let i_c180 = web.sample(180.0, 45.0);
293        assert!((i_c0 - i_c180).abs() < 0.001);
294
295        // C270 should mirror C90
296        let i_c90 = web.sample(90.0, 45.0);
297        let i_c270 = web.sample(270.0, 45.0);
298        assert!((i_c90 - i_c270).abs() < 0.001);
299    }
300
301    #[test]
302    fn test_from_eulumdat() {
303        let ldt = Eulumdat {
304            c_angles: vec![0.0, 90.0],
305            g_angles: vec![0.0, 90.0],
306            intensities: vec![vec![100.0, 50.0], vec![80.0, 40.0]],
307            symmetry: Symmetry::None,
308            ..Default::default()
309        };
310
311        let web = PhotometricWeb::from(&ldt);
312        assert_eq!(web.max_intensity(), 100.0);
313        assert_eq!(web.c_angles().len(), 2);
314    }
315}