Skip to main content

eulumdat_goniosim/
scene.rs

1//! Scene description and builder for luminaire simulation.
2
3use crate::catalog;
4use crate::geometry::{Primitive, SceneObject};
5use crate::material::{Material, MaterialParams};
6use crate::ray::{HitRecord, Ray};
7use crate::source::Source;
8use crate::MaterialId;
9use nalgebra::{Point3, Unit, Vector3};
10
11/// A complete scene ready for tracing.
12#[derive(Debug, Clone)]
13pub struct Scene {
14    pub sources: Vec<Source>,
15    pub objects: Vec<SceneObject>,
16    /// Internal physics materials, indexed by MaterialId.
17    materials: Vec<Material>,
18    /// User-facing material params, parallel to `materials`.
19    material_params: Vec<MaterialParams>,
20}
21
22impl Scene {
23    /// Create an empty scene.
24    pub fn new() -> Self {
25        Self {
26            sources: Vec::new(),
27            objects: Vec::new(),
28            materials: Vec::new(),
29            material_params: Vec::new(),
30        }
31    }
32
33    /// Add a light source.
34    pub fn add_source(&mut self, source: Source) {
35        self.sources.push(source);
36    }
37
38    /// Add a material from user-facing params. Returns the material ID.
39    pub fn add_material(&mut self, params: MaterialParams) -> MaterialId {
40        let id = self.materials.len();
41        self.materials.push(params.to_material());
42        self.material_params.push(params);
43        id
44    }
45
46    /// Add a scene object with a primitive, material, and label.
47    pub fn add_object(&mut self, primitive: Primitive, material: MaterialId, label: &str) -> usize {
48        let idx = self.objects.len();
49        self.objects.push(SceneObject {
50            primitive,
51            material,
52            label: label.to_string(),
53        });
54        idx
55    }
56
57    /// Get the internal physics material by ID.
58    pub fn material(&self, id: MaterialId) -> &Material {
59        &self.materials[id]
60    }
61
62    /// Get the user-facing material params by ID.
63    pub fn material_params(&self, id: MaterialId) -> &MaterialParams {
64        &self.material_params[id]
65    }
66
67    /// Total source flux in lumens.
68    pub fn total_source_flux(&self) -> f64 {
69        self.sources.iter().map(|s| s.flux_lm()).sum()
70    }
71
72    /// Find the nearest intersection of a ray with any scene object.
73    pub fn intersect(&self, ray: &Ray, t_min: f64, t_max: f64) -> Option<HitRecord> {
74        let mut closest: Option<HitRecord> = None;
75        let mut closest_t = t_max;
76
77        for obj in &self.objects {
78            if let Some(hit) = obj.primitive.intersect(ray, t_min, closest_t, obj.material) {
79                closest_t = hit.t;
80                closest = Some(hit);
81            }
82        }
83
84        closest
85    }
86}
87
88impl Default for Scene {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94// ---------------------------------------------------------------------------
95// Scene Builder
96// ---------------------------------------------------------------------------
97
98/// Which side of the source to place a reflector.
99#[derive(Debug, Clone, Copy)]
100pub enum ReflectorSide {
101    Left,
102    Right,
103    Back,
104    /// Cylindrical reflector surrounding the source.
105    Surround,
106}
107
108/// Placement of a transmissive cover relative to the light source.
109#[derive(Debug, Clone)]
110pub struct CoverPlacement {
111    /// Abstand zur Lichtquelle \[mm\] — along emission axis.
112    pub distance_mm: f64,
113    /// Cover width \[mm\].
114    pub width_mm: f64,
115    /// Cover height \[mm\].
116    pub height_mm: f64,
117}
118
119/// Placement of a reflector surface relative to the light source.
120#[derive(Debug, Clone)]
121pub struct ReflectorPlacement {
122    /// Abstand zur Lichtquelle \[mm\] — perpendicular to emission axis.
123    pub distance_mm: f64,
124    /// Reflector length along emission axis \[mm\].
125    pub length_mm: f64,
126    /// Which side of the source.
127    pub side: ReflectorSide,
128}
129
130/// High-level builder for common luminaire configurations.
131///
132/// Positions objects relative to the light source so the user
133/// specifies distances in mm, not 3D coordinates.
134pub struct SceneBuilder {
135    scene: Scene,
136    source_position: Point3<f64>,
137    source_direction: Unit<Vector3<f64>>,
138}
139
140impl SceneBuilder {
141    /// Start building a scene. Source at origin, emitting downward (-Z).
142    pub fn new() -> Self {
143        Self {
144            scene: Scene::new(),
145            source_position: Point3::origin(),
146            source_direction: Unit::new_unchecked(Vector3::new(0.0, 0.0, -1.0)),
147        }
148    }
149
150    /// Set the light source.
151    pub fn source(mut self, source: Source) -> Self {
152        self.scene.add_source(source);
153        self
154    }
155
156    /// Add a reflector/housing surface at a given distance from the source.
157    pub fn reflector(mut self, material: MaterialParams, placement: ReflectorPlacement) -> Self {
158        let mat_name = material.name.clone();
159        let mat_id = self.scene.add_material(material);
160        let d = placement.distance_mm / 1000.0; // mm → m
161        let l = placement.length_mm / 1000.0;
162
163        match placement.side {
164            ReflectorSide::Surround => {
165                // Cylindrical reflector around the source
166                let prim = Primitive::Cylinder {
167                    center: self.source_position + self.source_direction.as_ref() * (l / 2.0),
168                    axis: self.source_direction,
169                    radius: d,
170                    half_height: l / 2.0,
171                    capped: false,
172                };
173                self.scene
174                    .add_object(prim, mat_id, &format!("{mat_name} housing"));
175            }
176            ReflectorSide::Back => {
177                let normal = Unit::new_unchecked(-self.source_direction.into_inner());
178                let u_axis = perpendicular_axis(&self.source_direction);
179                let center = self.source_position - self.source_direction.as_ref() * d;
180                let prim = Primitive::Sheet {
181                    center,
182                    normal,
183                    u_axis,
184                    half_width: l / 2.0,
185                    half_height: l / 2.0,
186                    thickness: 0.001,
187                };
188                self.scene
189                    .add_object(prim, mat_id, &format!("{mat_name} back"));
190            }
191            ReflectorSide::Left => {
192                let u_axis = perpendicular_axis(&self.source_direction);
193                let normal = Unit::new_normalize(u_axis.into_inner());
194                let center = self.source_position - u_axis.as_ref() * d
195                    + self.source_direction.as_ref() * (l / 2.0);
196                let prim = Primitive::Sheet {
197                    center,
198                    normal,
199                    u_axis: self.source_direction,
200                    half_width: l / 2.0,
201                    half_height: l / 2.0,
202                    thickness: 0.001,
203                };
204                self.scene
205                    .add_object(prim, mat_id, &format!("{mat_name} left"));
206            }
207            ReflectorSide::Right => {
208                let u_axis = perpendicular_axis(&self.source_direction);
209                let normal = Unit::new_normalize(-u_axis.into_inner());
210                let center = self.source_position
211                    + u_axis.as_ref() * d
212                    + self.source_direction.as_ref() * (l / 2.0);
213                let prim = Primitive::Sheet {
214                    center,
215                    normal,
216                    u_axis: self.source_direction,
217                    half_width: l / 2.0,
218                    half_height: l / 2.0,
219                    thickness: 0.001,
220                };
221                self.scene
222                    .add_object(prim, mat_id, &format!("{mat_name} right"));
223            }
224        }
225
226        self
227    }
228
229    /// Add a transmissive cover (PMMA, glass) at a given distance from the source.
230    pub fn cover(mut self, material: MaterialParams, placement: CoverPlacement) -> Self {
231        let mat_name = material.name.clone();
232        let mat_id = self.scene.add_material(material);
233        let d = placement.distance_mm / 1000.0;
234        let w = placement.width_mm / 1000.0;
235        let h = placement.height_mm / 1000.0;
236
237        // Place the cover sheet perpendicular to the emission direction,
238        // at the given distance along the emission axis.
239        let center = self.source_position + self.source_direction.as_ref() * d;
240        let normal = Unit::new_unchecked(-self.source_direction.into_inner());
241        let u_axis = perpendicular_axis(&self.source_direction);
242
243        let thickness = self.scene.material_params[mat_id].thickness_mm / 1000.0;
244
245        let prim = Primitive::Sheet {
246            center,
247            normal,
248            u_axis,
249            half_width: w / 2.0,
250            half_height: h / 2.0,
251            thickness,
252        };
253        self.scene
254            .add_object(prim, mat_id, &format!("{mat_name} cover"));
255
256        self
257    }
258
259    /// Build the final scene.
260    pub fn build(self) -> Scene {
261        self.scene
262    }
263}
264
265impl Default for SceneBuilder {
266    fn default() -> Self {
267        Self::new()
268    }
269}
270
271/// Find a vector perpendicular to the given axis.
272fn perpendicular_axis(axis: &Unit<Vector3<f64>>) -> Unit<Vector3<f64>> {
273    let a = if axis.x.abs() > 0.9 {
274        Vector3::y_axis()
275    } else {
276        Vector3::x_axis()
277    };
278    Unit::new_normalize(axis.cross(a.as_ref()))
279}
280
281// ---------------------------------------------------------------------------
282// Preset scenes
283// ---------------------------------------------------------------------------
284
285/// Bare Lambertian emitter in free space.
286/// Expected result: cosine LVK.
287pub fn bare_lambertian(flux_lm: f64) -> Scene {
288    let mut scene = Scene::new();
289    scene.add_source(Source::Lambertian {
290        position: Point3::origin(),
291        normal: Unit::new_unchecked(Vector3::new(0.0, 0.0, -1.0)),
292        flux_lm,
293    });
294    scene
295}
296
297/// Bare isotropic point source.
298/// Expected result: constant cd in all directions.
299pub fn bare_isotropic(flux_lm: f64) -> Scene {
300    let mut scene = Scene::new();
301    scene.add_source(Source::Isotropic {
302        position: Point3::origin(),
303        flux_lm,
304    });
305    scene
306}
307
308/// LED with reflector housing.
309pub fn led_with_housing(flux_lm: f64, beam_angle_deg: f64) -> Scene {
310    SceneBuilder::new()
311        .source(Source::Led {
312            position: Point3::origin(),
313            direction: Unit::new_unchecked(Vector3::new(0.0, 0.0, -1.0)),
314            half_angle_deg: beam_angle_deg / 2.0,
315            flux_lm,
316        })
317        .reflector(
318            catalog::white_paint(),
319            ReflectorPlacement {
320                distance_mm: 25.0,
321                length_mm: 50.0,
322                side: ReflectorSide::Surround,
323            },
324        )
325        .build()
326}
327
328/// LED + housing + cover with configurable material and distance.
329pub fn led_housing_with_cover(
330    flux_lm: f64,
331    beam_angle_deg: f64,
332    cover_material: MaterialParams,
333    cover_distance_mm: f64,
334) -> Scene {
335    SceneBuilder::new()
336        .source(Source::Led {
337            position: Point3::origin(),
338            direction: Unit::new_unchecked(Vector3::new(0.0, 0.0, -1.0)),
339            half_angle_deg: beam_angle_deg / 2.0,
340            flux_lm,
341        })
342        .reflector(
343            catalog::white_paint(),
344            ReflectorPlacement {
345                distance_mm: 25.0,
346                length_mm: cover_distance_mm + 10.0,
347                side: ReflectorSide::Surround,
348            },
349        )
350        .cover(
351            cover_material,
352            CoverPlacement {
353                distance_mm: cover_distance_mm,
354                width_mm: 60.0,
355                height_mm: 60.0,
356            },
357        )
358        .build()
359}
360
361/// Round-trip validation: trace an existing LDT through empty space.
362pub fn roundtrip_validation(ldt: &eulumdat::Eulumdat) -> Scene {
363    let flux = ldt.total_luminous_flux();
364    let mut scene = Scene::new();
365    scene.add_source(Source::from_lvk(
366        Point3::origin(),
367        nalgebra::Rotation3::identity(),
368        ldt.clone(),
369        flux,
370    ));
371    scene
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn scene_builder_creates_objects() {
380        let scene = SceneBuilder::new()
381            .source(Source::Led {
382                position: Point3::origin(),
383                direction: Unit::new_unchecked(Vector3::new(0.0, 0.0, -1.0)),
384                half_angle_deg: 60.0,
385                flux_lm: 1000.0,
386            })
387            .reflector(
388                catalog::white_paint(),
389                ReflectorPlacement {
390                    distance_mm: 25.0,
391                    length_mm: 50.0,
392                    side: ReflectorSide::Surround,
393                },
394            )
395            .cover(
396                catalog::opal_pmma_3mm(),
397                CoverPlacement {
398                    distance_mm: 40.0,
399                    width_mm: 60.0,
400                    height_mm: 60.0,
401                },
402            )
403            .build();
404
405        assert_eq!(scene.sources.len(), 1);
406        assert_eq!(scene.objects.len(), 2); // housing + cover
407        assert!((scene.total_source_flux() - 1000.0).abs() < 0.01);
408    }
409
410    #[test]
411    fn bare_lambertian_has_no_objects() {
412        let scene = bare_lambertian(1000.0);
413        assert_eq!(scene.sources.len(), 1);
414        assert!(scene.objects.is_empty());
415    }
416}