Skip to main content

gldf_rs/
mapping.rs

1//! L3D to LDT Mapping Helper
2//!
3//! This module provides utilities to extract the relationship between
4//! L3D geometry files and LDT photometry files from GLDF structure.
5
6use crate::gldf::GldfProduct;
7use crate::FileBufGldf;
8
9/// Represents a mapping between an L3D geometry file and its associated LDT photometry file
10#[derive(Debug, Clone)]
11pub struct L3dLdtMapping {
12    /// The file ID of the L3D geometry file
13    pub l3d_file_id: String,
14    /// The file name of the L3D file
15    pub l3d_file_name: Option<String>,
16    /// The file ID of the associated LDT/IES photometry file
17    pub ldt_file_id: Option<String>,
18    /// The file name of the LDT/IES file
19    pub ldt_file_name: Option<String>,
20    /// The variant ID this mapping comes from
21    pub variant_id: String,
22    /// The emitter ID
23    pub emitter_id: Option<String>,
24}
25
26/// Per-emitter data for rendering with correct intensity and color
27#[derive(Debug, Clone, Default)]
28pub struct EmitterRenderData {
29    /// The LEO (Light Emitting Object) name from L3D structure.xml
30    pub leo_name: String,
31    /// The emitter ID from GLDF
32    pub emitter_id: String,
33    /// Rated luminous flux in lumens (lm)
34    pub luminous_flux: Option<i32>,
35    /// Correlated color temperature in Kelvin (K)
36    pub color_temperature: Option<i32>,
37    /// LDT file ID for photometry data
38    pub ldt_file_id: Option<String>,
39    /// LDT file name
40    pub ldt_file_name: Option<String>,
41    /// Emergency behavior (e.g., "Combined", "EmergencyOnly")
42    pub emergency_behavior: Option<String>,
43}
44
45/// Mounting type for luminaire positioning
46#[derive(Debug, Clone, PartialEq)]
47pub enum MountingType {
48    /// Ceiling mounted (recessed, surface, or pendant)
49    Ceiling,
50    /// Wall mounted
51    Wall,
52    /// Ground mounted (pole, free-standing)
53    Ground,
54    /// Working plane (table/desk)
55    WorkingPlane,
56}
57
58/// Mounting information extracted from GLDF variant
59#[derive(Debug, Clone, Default)]
60pub struct MountingInfo {
61    /// Primary mounting type
62    pub mounting_type: Option<MountingType>,
63    /// Recessed depth in mm (for ceiling/wall recessed)
64    pub recessed_depth_mm: Option<i32>,
65    /// Pendant length in mm (for ceiling pendant)
66    pub pendant_length_mm: Option<f64>,
67    /// Mounting height in mm (for wall mounted)
68    pub mounting_height_mm: Option<i32>,
69    /// Pole height in mm (for ground pole-top/pole-integrated)
70    pub pole_height_mm: Option<i32>,
71    /// Whether surface mounted
72    pub is_surface_mounted: bool,
73    /// Whether free-standing
74    pub is_free_standing: bool,
75}
76
77impl MountingInfo {
78    /// Get the Y offset in meters for positioning in 3D scene
79    /// Returns (y_position, is_from_ceiling)
80    pub fn get_position_offset(&self, room_height: f32) -> (f32, bool) {
81        match self.mounting_type {
82            Some(MountingType::Ceiling) => {
83                if let Some(pendant_mm) = self.pendant_length_mm {
84                    // Pendant: hang down from ceiling
85                    (room_height - (pendant_mm as f32 / 1000.0), true)
86                } else if self.recessed_depth_mm.is_some() {
87                    // Recessed: flush with ceiling
88                    (room_height, true)
89                } else {
90                    // Surface mounted: slightly below ceiling
91                    (room_height - 0.05, true)
92                }
93            }
94            Some(MountingType::Wall) => {
95                // Wall: use mounting height or default to 2m
96                let height_m = self.mounting_height_mm.unwrap_or(2000) as f32 / 1000.0;
97                (height_m, false)
98            }
99            Some(MountingType::Ground) => {
100                if let Some(pole_mm) = self.pole_height_mm {
101                    // Pole mounted: at pole height
102                    (pole_mm as f32 / 1000.0, false)
103                } else if self.is_free_standing {
104                    // Free-standing on ground
105                    (0.0, false)
106                } else {
107                    // Default ground level
108                    (0.0, false)
109                }
110            }
111            Some(MountingType::WorkingPlane) => {
112                // Working plane: typical desk height ~0.75m
113                (0.75, false)
114            }
115            None => {
116                // Default: assume ceiling mounted
117                (room_height - 0.1, true)
118            }
119        }
120    }
121}
122
123/// All emitter data for a variant
124#[derive(Debug, Clone, Default)]
125pub struct VariantEmitterData {
126    /// The variant ID
127    pub variant_id: String,
128    /// L3D file ID
129    pub l3d_file_id: Option<String>,
130    /// L3D file name
131    pub l3d_file_name: Option<String>,
132    /// Per-emitter render data
133    pub emitters: Vec<EmitterRenderData>,
134    /// Mounting information for positioning
135    pub mounting: MountingInfo,
136}
137
138/// Extract L3D to LDT mappings from a GLDF product
139///
140/// This function traverses the GLDF structure to find all L3D geometry files
141/// and their associated LDT photometry files through the variant/emitter chain.
142///
143/// The mapping chain is:
144/// ```text
145/// Variant → Geometry → ModelGeometryReference (geometry_id)
146///     → ModelGeometry → GeometryFileReference (file_id) → L3D file
147///
148/// Variant → Geometry → ModelGeometryReference → EmitterReference (emitter_id)
149///     → Emitter → FixedLightEmitter/ChangeableLightEmitter
150///     → PhotometryReference (photometry_id)
151///     → Photometry → PhotometryFileReference (file_id) → LDT file
152/// ```
153pub fn get_l3d_ldt_mappings(gldf: &GldfProduct) -> Vec<L3dLdtMapping> {
154    let mut mappings = Vec::new();
155
156    // Get variants
157    let Some(variants) = &gldf.product_definitions.variants else {
158        return mappings;
159    };
160
161    // Get general definitions for lookups
162    let general_defs = &gldf.general_definitions;
163
164    // Build lookup maps
165    let geometries = general_defs.geometries.as_ref();
166    let emitters = general_defs.emitters.as_ref();
167    let photometries = general_defs.photometries.as_ref();
168    let files = &general_defs.files.file;
169
170    // Helper to find file name by file_id
171    let get_file_name = |file_id: &str| -> Option<String> {
172        files
173            .iter()
174            .find(|f| f.id == file_id)
175            .map(|f| f.file_name.clone())
176    };
177
178    // Helper to find L3D file_id from geometry_id
179    let get_l3d_file_id = |geometry_id: &str| -> Option<String> {
180        geometries.and_then(|g| {
181            g.model_geometry
182                .iter()
183                .find(|mg| mg.id == geometry_id)
184                .and_then(|mg| mg.geometry_file_reference.first())
185                .map(|gfr| gfr.file_id.clone())
186        })
187    };
188
189    // Helper to find photometry_id from emitter_id
190    let get_photometry_id = |emitter_id: &str| -> Option<String> {
191        emitters.and_then(|e| {
192            e.emitter
193                .iter()
194                .find(|em| em.id == emitter_id)
195                .and_then(|em| {
196                    // Check fixed light emitters first
197                    em.fixed_light_emitter
198                        .first()
199                        .map(|fle| fle.photometry_reference.photometry_id.clone())
200                        .or_else(|| {
201                            // Then check changeable light emitters
202                            em.changeable_light_emitter
203                                .first()
204                                .map(|cle| cle.photometry_reference.photometry_id.clone())
205                        })
206                })
207        })
208    };
209
210    // Helper to find LDT file_id from photometry_id
211    let get_ldt_file_id = |photometry_id: &str| -> Option<String> {
212        photometries.and_then(|p| {
213            p.photometry
214                .iter()
215                .find(|ph| ph.id == photometry_id)
216                .and_then(|ph| ph.photometry_file_reference.as_ref())
217                .map(|pfr| pfr.file_id.clone())
218        })
219    };
220
221    // Iterate through variants
222    for variant in &variants.variant {
223        // Check for geometry in variant
224        if let Some(geometry) = &variant.geometry {
225            // Handle model geometry reference
226            if let Some(model_geo_ref) = &geometry.model_geometry_reference {
227                let geometry_id = &model_geo_ref.geometry_id;
228
229                // Get L3D file info
230                let l3d_file_id = get_l3d_file_id(geometry_id);
231                let l3d_file_name = l3d_file_id.as_ref().and_then(|id| get_file_name(id));
232
233                // Get LDT file info from emitter references
234                let mut ldt_file_id = None;
235                let mut ldt_file_name = None;
236                let mut emitter_id = None;
237
238                for emitter_ref in &model_geo_ref.emitter_reference {
239                    emitter_id = Some(emitter_ref.emitter_id.clone());
240
241                    if let Some(photometry_id) = get_photometry_id(&emitter_ref.emitter_id) {
242                        if let Some(file_id) = get_ldt_file_id(&photometry_id) {
243                            ldt_file_name = get_file_name(&file_id);
244                            ldt_file_id = Some(file_id);
245                            break; // Use first found
246                        }
247                    }
248                }
249
250                if let Some(l3d_id) = l3d_file_id {
251                    mappings.push(L3dLdtMapping {
252                        l3d_file_id: l3d_id,
253                        l3d_file_name,
254                        ldt_file_id,
255                        ldt_file_name,
256                        variant_id: variant.id.clone(),
257                        emitter_id,
258                    });
259                }
260            }
261
262            // Handle simple geometry reference (has emitter_id directly)
263            if let Some(simple_geo_ref) = &geometry.simple_geometry_reference {
264                let emitter_id = &simple_geo_ref.emitter_id;
265
266                // Get LDT file info
267                if let Some(photometry_id) = get_photometry_id(emitter_id) {
268                    if let Some(ldt_file_id) = get_ldt_file_id(&photometry_id) {
269                        let ldt_file_name = get_file_name(&ldt_file_id);
270
271                        // Simple geometry doesn't have L3D, but we record the LDT mapping
272                        mappings.push(L3dLdtMapping {
273                            l3d_file_id: simple_geo_ref.geometry_id.clone(),
274                            l3d_file_name: None, // Simple geometry, no L3D file
275                            ldt_file_id: Some(ldt_file_id),
276                            ldt_file_name,
277                            variant_id: variant.id.clone(),
278                            emitter_id: Some(emitter_id.clone()),
279                        });
280                    }
281                }
282            }
283        }
284    }
285
286    mappings
287}
288
289/// Get all L3D files from a GLDF with their associated LDT data
290pub fn get_l3d_files_with_ldt(gldf: &FileBufGldf) -> Vec<L3dWithLdt> {
291    let mappings = get_l3d_ldt_mappings(&gldf.gldf);
292    let mut results = Vec::new();
293
294    for mapping in mappings {
295        // Find L3D content
296        let l3d_content = gldf
297            .files
298            .iter()
299            .find(|f| {
300                f.file_id.as_ref() == Some(&mapping.l3d_file_id)
301                    || f.name.as_ref() == mapping.l3d_file_name.as_ref()
302            })
303            .and_then(|f| f.content.clone());
304
305        // Find LDT content
306        let ldt_content = mapping.ldt_file_id.as_ref().and_then(|ldt_id| {
307            gldf.files
308                .iter()
309                .find(|f| {
310                    f.file_id.as_ref() == Some(ldt_id)
311                        || f.name.as_ref() == mapping.ldt_file_name.as_ref()
312                })
313                .and_then(|f| f.content.clone())
314        });
315
316        if l3d_content.is_some() {
317            results.push(L3dWithLdt {
318                l3d_file_name: mapping
319                    .l3d_file_name
320                    .unwrap_or_else(|| mapping.l3d_file_id.clone()),
321                l3d_content,
322                ldt_file_name: mapping.ldt_file_name,
323                ldt_content,
324                variant_id: mapping.variant_id,
325            });
326        }
327    }
328
329    results
330}
331
332/// L3D file with its associated LDT content
333#[derive(Debug, Clone)]
334pub struct L3dWithLdt {
335    /// The L3D file name
336    pub l3d_file_name: String,
337    /// The L3D file content (binary)
338    pub l3d_content: Option<Vec<u8>>,
339    /// The associated LDT file name
340    pub ldt_file_name: Option<String>,
341    /// The LDT file content (text)
342    pub ldt_content: Option<Vec<u8>>,
343    /// The variant ID this comes from
344    pub variant_id: String,
345}
346
347impl L3dWithLdt {
348    /// Get the LDT content as a string (for parsing externally)
349    pub fn ldt_as_string(&self) -> Option<String> {
350        self.ldt_content
351            .as_ref()
352            .and_then(|content| std::str::from_utf8(content).ok().map(|s| s.to_string()))
353    }
354
355    /// Check if this mapping has both L3D and LDT content
356    pub fn has_both(&self) -> bool {
357        self.l3d_content.is_some() && self.ldt_content.is_some()
358    }
359
360    /// Check if this mapping has L3D content
361    pub fn has_l3d(&self) -> bool {
362        self.l3d_content.is_some()
363    }
364
365    /// Check if this mapping has LDT content
366    pub fn has_ldt(&self) -> bool {
367        self.ldt_content.is_some()
368    }
369}
370
371/// Find the first L3D file with LDT from a GLDF
372pub fn get_first_l3d_with_ldt(gldf: &FileBufGldf) -> Option<L3dWithLdt> {
373    get_l3d_files_with_ldt(gldf).into_iter().next()
374}
375
376/// Get per-emitter render data for a specific variant
377///
378/// This extracts the complete rendering information for each emitter in a variant:
379/// - LEO name (Light Emitting Object name from L3D)
380/// - Luminous flux in lumens
381/// - Color temperature in Kelvin
382/// - Associated photometry file
383///
384/// # Example
385///
386/// ```ignore
387/// let emitter_data = get_variant_emitter_data(&gldf, "variant_1");
388/// for emitter in &emitter_data.emitters {
389///     println!("{}: {} lm, {} K", emitter.leo_name,
390///         emitter.luminous_flux.unwrap_or(0),
391///         emitter.color_temperature.unwrap_or(3000));
392/// }
393/// ```
394pub fn get_variant_emitter_data(gldf: &GldfProduct, variant_id: &str) -> VariantEmitterData {
395    let mut result = VariantEmitterData {
396        variant_id: variant_id.to_string(),
397        ..Default::default()
398    };
399
400    // Get variants
401    let Some(variants) = &gldf.product_definitions.variants else {
402        return result;
403    };
404
405    // Find the specific variant
406    let Some(variant) = variants.variant.iter().find(|v| v.id == variant_id) else {
407        return result;
408    };
409
410    // Get general definitions for lookups
411    let general_defs = &gldf.general_definitions;
412    let geometries = general_defs.geometries.as_ref();
413    let emitters = general_defs.emitters.as_ref();
414    let light_sources = general_defs.light_sources.as_ref();
415    let photometries = general_defs.photometries.as_ref();
416    let files = &general_defs.files.file;
417
418    // Helper to find file name by file_id
419    let get_file_name = |file_id: &str| -> Option<String> {
420        files
421            .iter()
422            .find(|f| f.id == file_id)
423            .map(|f| f.file_name.clone())
424    };
425
426    // Helper to find L3D file_id from geometry_id
427    let get_l3d_file_id = |geometry_id: &str| -> Option<String> {
428        geometries.and_then(|g| {
429            g.model_geometry
430                .iter()
431                .find(|mg| mg.id == geometry_id)
432                .and_then(|mg| mg.geometry_file_reference.first())
433                .map(|gfr| gfr.file_id.clone())
434        })
435    };
436
437    // Helper to find photometry file info from emitter_id
438    let get_photometry_info = |emitter_id: &str| -> (Option<String>, Option<String>) {
439        let photometry_id = emitters.and_then(|e| {
440            e.emitter
441                .iter()
442                .find(|em| em.id == emitter_id)
443                .and_then(|em| {
444                    em.fixed_light_emitter
445                        .first()
446                        .map(|fle| fle.photometry_reference.photometry_id.clone())
447                        .or_else(|| {
448                            em.changeable_light_emitter
449                                .first()
450                                .map(|cle| cle.photometry_reference.photometry_id.clone())
451                        })
452                })
453        });
454
455        if let Some(ref photo_id) = photometry_id {
456            let ldt_file_id = photometries.and_then(|p| {
457                p.photometry
458                    .iter()
459                    .find(|ph| ph.id == *photo_id)
460                    .and_then(|ph| ph.photometry_file_reference.as_ref())
461                    .map(|pfr| pfr.file_id.clone())
462            });
463            let ldt_file_name = ldt_file_id.as_ref().and_then(|id| get_file_name(id));
464            (ldt_file_id, ldt_file_name)
465        } else {
466            (None, None)
467        }
468    };
469
470    // Helper to get emitter details (flux, color temp, emergency behavior)
471    let get_emitter_details = |emitter_id: &str| -> (Option<i32>, Option<i32>, Option<String>) {
472        let emitter = emitters.and_then(|e| e.emitter.iter().find(|em| em.id == emitter_id));
473
474        let Some(emitter) = emitter else {
475            return (None, None, None);
476        };
477
478        // Get from fixed light emitter
479        if let Some(fle) = emitter.fixed_light_emitter.first() {
480            let flux = fle.rated_luminous_flux;
481            let emergency = fle.emergency_behaviour.clone();
482
483            // Get color temperature from referenced light source
484            let color_temp = fle
485                .light_source_reference
486                .fixed_light_source_id
487                .as_ref()
488                .and_then(|ls_id| {
489                    light_sources.and_then(|lss| {
490                        lss.fixed_light_source
491                            .iter()
492                            .find(|ls| ls.id == *ls_id)
493                            .and_then(|ls| ls.color_information.as_ref())
494                            .and_then(|ci| ci.correlated_color_temperature)
495                    })
496                });
497
498            return (flux, color_temp, emergency);
499        }
500
501        // Get from changeable light emitter
502        if let Some(cle) = emitter.changeable_light_emitter.first() {
503            let emergency = cle.emergency_behaviour.clone();
504            // Changeable emitters don't have rated_luminous_flux directly
505            return (None, None, emergency);
506        }
507
508        (None, None, None)
509    };
510
511    // Process the variant's geometry
512    if let Some(geometry) = &variant.geometry {
513        if let Some(model_geo_ref) = &geometry.model_geometry_reference {
514            let geometry_id = &model_geo_ref.geometry_id;
515
516            // Get L3D file info
517            result.l3d_file_id = get_l3d_file_id(geometry_id);
518            result.l3d_file_name = result.l3d_file_id.as_ref().and_then(|id| get_file_name(id));
519
520            // Process each emitter reference
521            for emitter_ref in &model_geo_ref.emitter_reference {
522                let (ldt_file_id, ldt_file_name) = get_photometry_info(&emitter_ref.emitter_id);
523                let (flux, color_temp, emergency) = get_emitter_details(&emitter_ref.emitter_id);
524
525                result.emitters.push(EmitterRenderData {
526                    leo_name: emitter_ref.emitter_object_external_name.clone(),
527                    emitter_id: emitter_ref.emitter_id.clone(),
528                    luminous_flux: flux,
529                    color_temperature: color_temp,
530                    ldt_file_id,
531                    ldt_file_name,
532                    emergency_behavior: emergency,
533                });
534            }
535        }
536    }
537
538    // Extract mounting information from variant
539    result.mounting = extract_mounting_info(variant);
540
541    result
542}
543
544/// Extract mounting information from a GLDF variant.
545///
546/// When multiple mounting types are present, priority is:
547/// Wall > Ceiling > Ground > WorkingPlane.
548/// Wall is preferred because it carries explicit mounting height.
549/// Supplementary data (pole height, etc.) is still collected from all mountings.
550fn extract_mounting_info(variant: &crate::gldf::product_definitions::Variant) -> MountingInfo {
551    let mut info = MountingInfo::default();
552
553    let Some(mountings) = &variant.mountings else {
554        return info;
555    };
556
557    // Collect data from all mounting types, set mounting_type by priority
558
559    // Check wall mounting (highest priority — has explicit mounting height)
560    if let Some(wall) = &mountings.wall {
561        if info.mounting_type.is_none() {
562            info.mounting_type = Some(MountingType::Wall);
563        }
564        info.mounting_height_mm = Some(wall.mounting_height);
565
566        if let Some(recessed) = &wall.recessed {
567            info.recessed_depth_mm = Some(recessed.recessed_depth);
568        }
569
570        if wall.surface_mounted.is_some() {
571            info.is_surface_mounted = true;
572        }
573    }
574
575    // Check ceiling mounting
576    if let Some(ceiling) = &mountings.ceiling {
577        if info.mounting_type.is_none() {
578            info.mounting_type = Some(MountingType::Ceiling);
579        }
580
581        if let Some(recessed) = &ceiling.recessed {
582            info.recessed_depth_mm = Some(recessed.recessed_depth);
583        }
584
585        if let Some(pendant) = &ceiling.pendant {
586            info.pendant_length_mm = Some(pendant.pendant_length);
587        }
588
589        if ceiling.surface_mounted.is_some() {
590            info.is_surface_mounted = true;
591        }
592    }
593
594    // Check ground mounting
595    if let Some(ground) = &mountings.ground {
596        if info.mounting_type.is_none() {
597            info.mounting_type = Some(MountingType::Ground);
598        }
599
600        if let Some(pole_top) = &ground.pole_top {
601            info.pole_height_mm = pole_top.get_pole_height();
602        }
603
604        if let Some(pole_integrated) = &ground.pole_integrated {
605            if info.pole_height_mm.is_none() {
606                info.pole_height_mm = pole_integrated.get_pole_height();
607            }
608        }
609
610        if ground.free_standing.is_some() {
611            info.is_free_standing = true;
612        }
613
614        if ground.surface_mounted.is_some() {
615            info.is_surface_mounted = true;
616        }
617
618        if let Some(recessed) = &ground.recessed {
619            if info.recessed_depth_mm.is_none() {
620                info.recessed_depth_mm = Some(recessed.recessed_depth);
621            }
622        }
623    }
624
625    // Check working plane
626    if let Some(working_plane) = &mountings.working_plane {
627        if info.mounting_type.is_none() {
628            info.mounting_type = Some(MountingType::WorkingPlane);
629        }
630
631        if working_plane.free_standing.is_some() {
632            info.is_free_standing = true;
633        }
634    }
635
636    info
637}
638
639#[cfg(test)]
640mod tests {
641    use super::*;
642
643    #[test]
644    fn test_mapping_creation() {
645        // Test would require a sample GLDF file
646        let mapping = L3dLdtMapping {
647            l3d_file_id: "geo_001".to_string(),
648            l3d_file_name: Some("luminaire.l3d".to_string()),
649            ldt_file_id: Some("photo_001".to_string()),
650            ldt_file_name: Some("luminaire.ldt".to_string()),
651            variant_id: "variant_1".to_string(),
652            emitter_id: Some("emitter_1".to_string()),
653        };
654
655        assert_eq!(mapping.l3d_file_id, "geo_001");
656        assert_eq!(mapping.ldt_file_id, Some("photo_001".to_string()));
657    }
658}