Skip to main content

eulumdat_goniosim/
source.rs

1//! Photon emission source models.
2
3use crate::ray::Ray;
4use eulumdat::Eulumdat;
5use nalgebra::{Point3, Rotation3, Unit, Vector3};
6use rand::Rng;
7use std::f64::consts::PI;
8
9/// Light source types for photon emission.
10#[derive(Debug, Clone)]
11pub enum Source {
12    /// Uniform emission in all directions (4pi steradians).
13    Isotropic { position: Point3<f64>, flux_lm: f64 },
14
15    /// Cosine-weighted hemisphere (ideal diffuse emitter).
16    Lambertian {
17        position: Point3<f64>,
18        normal: Unit<Vector3<f64>>,
19        flux_lm: f64,
20    },
21
22    /// Directional LED with beam angle.
23    Led {
24        position: Point3<f64>,
25        direction: Unit<Vector3<f64>>,
26        half_angle_deg: f64,
27        flux_lm: f64,
28    },
29
30    /// Line source (LED strip) — samples random point along segment.
31    LineSource {
32        start: Point3<f64>,
33        end: Point3<f64>,
34        normal: Unit<Vector3<f64>>,
35        half_angle_deg: f64,
36        flux_lm: f64,
37    },
38
39    /// Rectangular diffuse area emitter (Lambertian).
40    /// Emits cosine-weighted into the hemisphere defined by `normal`.
41    /// Position is sampled uniformly over the rectangle.
42    AreaSource {
43        center: Point3<f64>,
44        normal: Unit<Vector3<f64>>,
45        u_axis: Unit<Vector3<f64>>,
46        half_width: f64,
47        half_height: f64,
48        flux_lm: f64,
49    },
50
51    /// Emit according to an existing LDT/IES distribution (for validation).
52    FromLvk {
53        position: Point3<f64>,
54        orientation: Rotation3<f64>,
55        eulumdat: Box<Eulumdat>,
56        flux_lm: f64,
57        /// Pre-computed CDF for efficient sampling (built automatically).
58        cdf: LvkCdf,
59    },
60}
61
62impl Source {
63    /// Create a FromLvk source from an Eulumdat, pre-computing the sampling CDF.
64    pub fn from_lvk(
65        position: Point3<f64>,
66        orientation: Rotation3<f64>,
67        eulumdat: Eulumdat,
68        flux_lm: f64,
69    ) -> Self {
70        let cdf = LvkCdf::build(&eulumdat);
71        Source::FromLvk {
72            position,
73            orientation,
74            eulumdat: Box::new(eulumdat),
75            flux_lm,
76            cdf,
77        }
78    }
79
80    /// Total luminous flux of the source in lumens.
81    pub fn flux_lm(&self) -> f64 {
82        match self {
83            Source::Isotropic { flux_lm, .. } => *flux_lm,
84            Source::Lambertian { flux_lm, .. } => *flux_lm,
85            Source::Led { flux_lm, .. } => *flux_lm,
86            Source::LineSource { flux_lm, .. } => *flux_lm,
87            Source::AreaSource { flux_lm, .. } => *flux_lm,
88            Source::FromLvk { flux_lm, .. } => *flux_lm,
89        }
90    }
91
92    /// Sample a photon ray from this source.
93    pub fn sample<R: Rng>(&self, rng: &mut R) -> Ray {
94        match self {
95            Source::Isotropic { position, .. } => {
96                let dir = random_sphere(rng);
97                Ray::new(*position, dir)
98            }
99
100            Source::Lambertian {
101                position, normal, ..
102            } => {
103                let dir = random_cosine_hemisphere(normal, rng);
104                Ray::new(*position, dir)
105            }
106
107            Source::Led {
108                position,
109                direction,
110                half_angle_deg,
111                ..
112            } => {
113                let dir = random_cone(direction, *half_angle_deg, rng);
114                Ray::new(*position, dir)
115            }
116
117            Source::LineSource {
118                start,
119                end,
120                normal,
121                half_angle_deg,
122                ..
123            } => {
124                // Random point along line segment
125                let t: f64 = rng.random();
126                let origin = start + t * (end - start);
127                let dir = random_cone(normal, *half_angle_deg, rng);
128                Ray::new(origin, dir)
129            }
130
131            Source::AreaSource {
132                center,
133                normal,
134                u_axis,
135                half_width,
136                half_height,
137                ..
138            } => {
139                // Random position on rectangle
140                let u: f64 = (rng.random::<f64>() * 2.0 - 1.0) * half_width;
141                let v_axis = Unit::new_normalize(normal.cross(u_axis.as_ref()));
142                let v: f64 = (rng.random::<f64>() * 2.0 - 1.0) * half_height;
143                let origin = center + u * u_axis.as_ref() + v * v_axis.as_ref();
144                let dir = random_cosine_hemisphere(normal, rng);
145                Ray::new(origin, dir)
146            }
147
148            Source::FromLvk {
149                position,
150                orientation,
151                cdf,
152                ..
153            } => {
154                // Sample direction from pre-computed CDF (no rejection, no bias)
155                let (c_angle, g_angle) = cdf.sample(rng);
156
157                let g_rad = g_angle.to_radians();
158                let c_rad = c_angle.to_radians();
159
160                // In photometric coords: gamma=0 is nadir (-Z), C0 is +X
161                let dir_local = Vector3::new(
162                    g_rad.sin() * c_rad.cos(),
163                    g_rad.sin() * c_rad.sin(),
164                    -g_rad.cos(),
165                );
166
167                let dir_world = orientation * dir_local;
168                Ray::new(*position, Unit::new_normalize(dir_world))
169            }
170        }
171    }
172}
173
174// ---------------------------------------------------------------------------
175// Random direction helpers
176// ---------------------------------------------------------------------------
177
178/// Uniform random direction on the unit sphere.
179fn random_sphere<R: Rng>(rng: &mut R) -> Unit<Vector3<f64>> {
180    let z: f64 = 2.0 * rng.random::<f64>() - 1.0;
181    let r = (1.0 - z * z).max(0.0).sqrt();
182    let phi = 2.0 * PI * rng.random::<f64>();
183    Unit::new_normalize(Vector3::new(r * phi.cos(), r * phi.sin(), z))
184}
185
186/// Cosine-weighted random direction in hemisphere around normal.
187fn random_cosine_hemisphere<R: Rng>(
188    normal: &Unit<Vector3<f64>>,
189    rng: &mut R,
190) -> Unit<Vector3<f64>> {
191    let u1: f64 = rng.random();
192    let u2: f64 = rng.random();
193    let r = u1.sqrt();
194    let theta = 2.0 * PI * u2;
195    let x = r * theta.cos();
196    let y = r * theta.sin();
197    let z = (1.0 - u1).sqrt();
198
199    let (tangent, bitangent) = build_onb(normal);
200    let dir = x * tangent.as_ref() + y * bitangent.as_ref() + z * normal.as_ref();
201    Unit::new_normalize(dir)
202}
203
204/// Random direction within a cone around a given axis.
205fn random_cone<R: Rng>(
206    axis: &Unit<Vector3<f64>>,
207    half_angle_deg: f64,
208    rng: &mut R,
209) -> Unit<Vector3<f64>> {
210    let cos_max = (half_angle_deg.to_radians()).cos();
211    let u: f64 = rng.random();
212    let cos_theta = 1.0 - u * (1.0 - cos_max);
213    let sin_theta = (1.0 - cos_theta * cos_theta).max(0.0).sqrt();
214    let phi = 2.0 * PI * rng.random::<f64>();
215
216    let (tangent, bitangent) = build_onb(axis);
217    let dir = sin_theta * phi.cos() * tangent.as_ref()
218        + sin_theta * phi.sin() * bitangent.as_ref()
219        + cos_theta * axis.as_ref();
220    Unit::new_normalize(dir)
221}
222
223/// Build an orthonormal basis from a single vector.
224fn build_onb(n: &Unit<Vector3<f64>>) -> (Unit<Vector3<f64>>, Unit<Vector3<f64>>) {
225    let a = if n.x.abs() > 0.9 {
226        Vector3::y_axis()
227    } else {
228        Vector3::x_axis()
229    };
230    let t = Unit::new_normalize(n.cross(a.as_ref()));
231    let b = Unit::new_normalize(n.cross(t.as_ref()));
232    (t, b)
233}
234
235// ---------------------------------------------------------------------------
236// LVK importance sampling via pre-computed CDF
237// ---------------------------------------------------------------------------
238
239/// Pre-computed CDF table for efficient LVK sampling.
240/// Built once per LDT, then sampled in O(log n) via binary search.
241/// Pre-computed CDF table for efficient LVK sampling.
242#[derive(Debug, Clone)]
243pub struct LvkCdf {
244    /// Gamma angles sampled at 1° resolution
245    pub g_steps: usize,
246    /// C angles sampled at 5° resolution
247    pub c_steps: usize,
248    /// g_max from LDT data
249    pub g_max: f64,
250    /// Marginal CDF over gamma: P(G <= g)
251    pub marginal_g: Vec<f64>,
252    /// Conditional CDF over C for each gamma bin: P(C <= c | G=g)
253    pub conditional_c: Vec<Vec<f64>>,
254}
255
256impl LvkCdf {
257    /// Build CDF from an Eulumdat. Used by Source::from_lvk and GPU tracer.
258    #[allow(clippy::needless_range_loop)]
259    pub fn build(ldt: &Eulumdat) -> Self {
260        let g_max = ldt.g_angles.last().copied().unwrap_or(180.0);
261        let g_step: f64 = 1.0; // 1° resolution
262        let c_step: f64 = 5.0; // 5° resolution
263        let g_steps = (g_max / g_step).ceil() as usize + 1;
264        let c_steps = (360.0 / c_step).ceil() as usize;
265
266        // Build the 2D PDF: f(c,g) = I(c,g) * sin(g)
267        let mut pdf = vec![vec![0.0f64; c_steps]; g_steps];
268        for gi in 0..g_steps {
269            let g = (gi as f64 * g_step).min(g_max);
270            let sin_g = g.to_radians().sin();
271            for ci in 0..c_steps {
272                let c = ci as f64 * c_step;
273                pdf[gi][ci] = ldt.sample(c, g) * sin_g;
274            }
275        }
276
277        // Marginal over gamma: sum over C for each g
278        let marginal_unnorm: Vec<f64> = pdf.iter().map(|row| row.iter().sum::<f64>()).collect();
279
280        // Build marginal CDF
281        let mut marginal_g = vec![0.0; g_steps];
282        let mut cum = 0.0;
283        for gi in 0..g_steps {
284            cum += marginal_unnorm[gi];
285            marginal_g[gi] = cum;
286        }
287        // Normalize
288        if cum > 0.0 {
289            for v in &mut marginal_g {
290                *v /= cum;
291            }
292        }
293
294        // Build conditional CDF over C for each gamma
295        let mut conditional_c = vec![vec![0.0; c_steps]; g_steps];
296        for gi in 0..g_steps {
297            let row_sum: f64 = pdf[gi].iter().sum();
298            let mut ccum = 0.0;
299            for ci in 0..c_steps {
300                ccum += pdf[gi][ci];
301                conditional_c[gi][ci] = if row_sum > 0.0 {
302                    ccum / row_sum
303                } else {
304                    (ci + 1) as f64 / c_steps as f64
305                };
306            }
307        }
308
309        Self {
310            g_steps,
311            c_steps,
312            g_max,
313            marginal_g,
314            conditional_c,
315        }
316    }
317
318    fn sample<R: Rng>(&self, rng: &mut R) -> (f64, f64) {
319        // Sample gamma from marginal CDF
320        let u_g: f64 = rng.random();
321        let gi = match self
322            .marginal_g
323            .binary_search_by(|v| v.partial_cmp(&u_g).unwrap())
324        {
325            Ok(i) => i,
326            Err(i) => i.min(self.g_steps - 1),
327        };
328        let g = (gi as f64 / (self.g_steps - 1).max(1) as f64) * self.g_max;
329
330        // Sample C from conditional CDF at this gamma
331        let u_c: f64 = rng.random();
332        let ci = match self.conditional_c[gi].binary_search_by(|v| v.partial_cmp(&u_c).unwrap()) {
333            Ok(i) => i,
334            Err(i) => i.min(self.c_steps - 1),
335        };
336        let c = ci as f64 * (360.0 / self.c_steps as f64);
337
338        (c, g)
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use rand::SeedableRng;
346    use rand_xoshiro::Xoshiro256PlusPlus;
347
348    #[test]
349    fn isotropic_samples_all_directions() {
350        let source = Source::Isotropic {
351            position: Point3::origin(),
352            flux_lm: 1000.0,
353        };
354        let mut rng = Xoshiro256PlusPlus::seed_from_u64(42);
355        let mut sum = Vector3::zeros();
356        let n = 10000;
357        for _ in 0..n {
358            let ray = source.sample(&mut rng);
359            sum += ray.direction.as_ref();
360        }
361        // Mean direction should be near zero for isotropic
362        let mean = sum / n as f64;
363        assert!(mean.norm() < 0.05, "Mean direction should be near zero");
364    }
365
366    #[test]
367    fn lambertian_samples_hemisphere() {
368        let source = Source::Lambertian {
369            position: Point3::origin(),
370            normal: Vector3::z_axis(),
371            flux_lm: 1000.0,
372        };
373        let mut rng = Xoshiro256PlusPlus::seed_from_u64(42);
374        for _ in 0..1000 {
375            let ray = source.sample(&mut rng);
376            assert!(
377                ray.direction.z >= -1e-6,
378                "Lambertian should only emit into positive hemisphere"
379            );
380        }
381    }
382
383    #[test]
384    fn led_samples_within_cone() {
385        let source = Source::Led {
386            position: Point3::origin(),
387            direction: Vector3::z_axis(),
388            half_angle_deg: 30.0,
389            flux_lm: 1000.0,
390        };
391        let mut rng = Xoshiro256PlusPlus::seed_from_u64(42);
392        let cos_limit = 30.0f64.to_radians().cos();
393        for _ in 0..1000 {
394            let ray = source.sample(&mut rng);
395            let cos_angle = ray.direction.dot(Vector3::z_axis().as_ref());
396            assert!(
397                cos_angle >= cos_limit - 1e-6,
398                "LED should sample within cone"
399            );
400        }
401    }
402}