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                let shifted = (c_normalized + 90.0).rem_euclid(360.0);
132                if shifted <= 180.0 {
133                    (shifted - 90.0).abs()
134                } else {
135                    (270.0 - shifted).abs()
136                }
137            }
138            Symmetry::BothPlanes => {
139                let in_first_half = c_normalized <= 180.0;
140                let c_in_half = if in_first_half {
141                    c_normalized
142                } else {
143                    360.0 - c_normalized
144                };
145                if c_in_half <= 90.0 {
146                    c_in_half
147                } else {
148                    180.0 - c_in_half
149                }
150            }
151        }
152    }
153
154    /// Find interpolation index and fraction for a target angle.
155    fn find_interpolation_index(&self, angles: &[f64], target: f64) -> (usize, f64) {
156        if angles.is_empty() {
157            return (0, 0.0);
158        }
159
160        if target <= angles[0] {
161            return (0, 0.0);
162        }
163
164        if target >= angles[angles.len() - 1] {
165            return (angles.len() - 1, 0.0);
166        }
167
168        for i in 0..angles.len() - 1 {
169            if target >= angles[i] && target <= angles[i + 1] {
170                let fraction = (target - angles[i]) / (angles[i + 1] - angles[i]);
171                return (i, fraction);
172            }
173        }
174
175        (angles.len() - 1, 0.0)
176    }
177
178    /// Perform bilinear interpolation.
179    fn bilinear_interpolate(&self, ci: usize, cf: f64, gi: usize, gf: f64) -> f64 {
180        let get = |c: usize, g: usize| -> f64 {
181            self.intensities
182                .get(c)
183                .and_then(|row| row.get(g))
184                .copied()
185                .unwrap_or(0.0)
186        };
187
188        let i00 = get(ci, gi);
189        let i01 = get(ci, gi + 1);
190        let i10 = get(ci + 1, gi);
191        let i11 = get(ci + 1, gi + 1);
192
193        // Bilinear interpolation
194        let i0 = i00 * (1.0 - gf) + i01 * gf;
195        let i1 = i10 * (1.0 - gf) + i11 * gf;
196
197        i0 * (1.0 - cf) + i1 * cf
198    }
199}
200
201impl From<&Eulumdat> for PhotometricWeb {
202    fn from(ldt: &Eulumdat) -> Self {
203        Self::new(
204            ldt.c_angles.clone(),
205            ldt.g_angles.clone(),
206            ldt.intensities.clone(),
207            ldt.symmetry,
208        )
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    fn create_test_web() -> PhotometricWeb {
217        PhotometricWeb::new(
218            vec![0.0, 90.0, 180.0, 270.0],
219            vec![0.0, 45.0, 90.0, 135.0, 180.0],
220            vec![
221                vec![100.0, 80.0, 50.0, 30.0, 10.0], // C0
222                vec![90.0, 70.0, 40.0, 25.0, 8.0],   // C90
223                vec![80.0, 60.0, 30.0, 20.0, 5.0],   // C180
224                vec![85.0, 65.0, 35.0, 22.0, 6.0],   // C270
225            ],
226            Symmetry::None,
227        )
228    }
229
230    #[test]
231    fn test_sample_exact_angles() {
232        let web = create_test_web();
233
234        // Test exact stored angles
235        let i = web.sample(0.0, 0.0);
236        assert!((i - 100.0).abs() < 0.001);
237
238        let i = web.sample(90.0, 45.0);
239        assert!((i - 70.0).abs() < 0.001);
240
241        let i = web.sample(180.0, 90.0);
242        assert!((i - 30.0).abs() < 0.001);
243    }
244
245    #[test]
246    fn test_sample_interpolated() {
247        let web = PhotometricWeb::new(
248            vec![0.0, 90.0],
249            vec![0.0, 90.0],
250            vec![
251                vec![100.0, 0.0], // C0
252                vec![100.0, 0.0], // C90
253            ],
254            Symmetry::None,
255        );
256
257        // Midpoint should be 50
258        let i = web.sample(0.0, 45.0);
259        assert!((i - 50.0).abs() < 0.001);
260    }
261
262    #[test]
263    fn test_sample_normalized() {
264        let web = create_test_web();
265
266        // Max intensity is 100, so normalized at (0, 0) should be 1.0
267        let n = web.sample_normalized(0.0, 0.0);
268        assert!((n - 1.0).abs() < 0.001);
269
270        // Half intensity should be 0.5
271        let n = web.sample_normalized(0.0, 90.0);
272        assert!((n - 0.5).abs() < 0.001);
273    }
274
275    #[test]
276    fn test_symmetry_both_planes() {
277        let web = PhotometricWeb::new(
278            vec![0.0, 45.0, 90.0],
279            vec![0.0, 45.0, 90.0],
280            vec![
281                vec![100.0, 80.0, 50.0], // C0
282                vec![90.0, 70.0, 40.0],  // C45
283                vec![80.0, 60.0, 30.0],  // C90
284            ],
285            Symmetry::BothPlanes,
286        );
287
288        // C180 should mirror C0
289        let i_c0 = web.sample(0.0, 45.0);
290        let i_c180 = web.sample(180.0, 45.0);
291        assert!((i_c0 - i_c180).abs() < 0.001);
292
293        // C270 should mirror C90
294        let i_c90 = web.sample(90.0, 45.0);
295        let i_c270 = web.sample(270.0, 45.0);
296        assert!((i_c90 - i_c270).abs() < 0.001);
297    }
298
299    #[test]
300    fn test_from_eulumdat() {
301        let mut ldt = Eulumdat::default();
302        ldt.c_angles = vec![0.0, 90.0];
303        ldt.g_angles = vec![0.0, 90.0];
304        ldt.intensities = vec![vec![100.0, 50.0], vec![80.0, 40.0]];
305        ldt.symmetry = Symmetry::None;
306
307        let web = PhotometricWeb::from(&ldt);
308        assert_eq!(web.max_intensity(), 100.0);
309        assert_eq!(web.c_angles().len(), 2);
310    }
311}