Skip to main content

bimifc_parser/
lighting.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Lighting data extraction from IFC files
6//!
7//! This module extracts light fixtures, light sources, and photometric data
8//! from IFC files for use with lighting analysis and GLDF viewers.
9
10use bimifc_model::{AttributeValue, DecodedEntity, EntityResolver, IfcType};
11use rustc_hash::FxHashMap;
12use serde::{Deserialize, Serialize};
13
14/// Extracted light fixture data
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct LightFixtureData {
17    /// Entity ID
18    pub id: u64,
19    /// GlobalId (GUID)
20    pub global_id: Option<String>,
21    /// Name
22    pub name: Option<String>,
23    /// Description
24    pub description: Option<String>,
25    /// Object type (predefined or user-defined)
26    pub object_type: Option<String>,
27    /// Position (X, Y, Z) in meters
28    pub position: (f64, f64, f64),
29    /// Associated storey name
30    pub storey: Option<String>,
31    /// Storey elevation
32    pub storey_elevation: Option<f64>,
33    /// Light fixture type reference
34    pub fixture_type: Option<LightFixtureTypeData>,
35    /// Light sources
36    pub light_sources: Vec<LightSourceData>,
37    /// Properties from property sets
38    pub properties: FxHashMap<String, PropertySetData>,
39}
40
41/// Light fixture type definition
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct LightFixtureTypeData {
44    pub id: u64,
45    pub name: Option<String>,
46    pub description: Option<String>,
47    pub predefined_type: Option<String>,
48}
49
50/// Light source data (goniometric, positional, etc.)
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct LightSourceData {
53    pub id: u64,
54    pub source_type: String,
55    /// Color temperature in Kelvin
56    pub color_temperature: Option<f64>,
57    /// Luminous flux in lumens
58    pub luminous_flux: Option<f64>,
59    /// Light emission source (LED, FLUORESCENT, etc.)
60    pub emission_source: Option<String>,
61    /// Intensity (cd)
62    pub intensity: Option<f64>,
63    /// Color RGB
64    pub color_rgb: Option<(f64, f64, f64)>,
65    /// Light distribution data
66    pub distribution: Option<LightDistributionData>,
67}
68
69/// Light distribution/photometry data
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct LightDistributionData {
72    /// Distribution type (TYPE_A, TYPE_B, TYPE_C)
73    pub distribution_type: String,
74    /// Distribution planes (C-planes for Type C)
75    pub planes: Vec<DistributionPlane>,
76}
77
78/// A single distribution plane
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct DistributionPlane {
81    /// Main plane angle (C-angle for Type C)
82    pub main_angle: f64,
83    /// Intensity values at secondary angles (gamma angles)
84    pub intensities: Vec<(f64, f64)>, // (angle, intensity in cd)
85}
86
87/// Property set with values
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct PropertySetData {
90    pub name: String,
91    pub properties: FxHashMap<String, String>,
92}
93
94/// Complete lighting data from an IFC file
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct LightingExport {
97    /// Schema version
98    pub schema: String,
99    /// Project name
100    pub project_name: Option<String>,
101    /// Building name
102    pub building_name: Option<String>,
103    /// Building storeys
104    pub storeys: Vec<StoreyData>,
105    /// Light fixtures
106    pub light_fixtures: Vec<LightFixtureData>,
107    /// Light fixture types
108    pub light_fixture_types: Vec<LightFixtureTypeData>,
109    /// Summary statistics
110    pub summary: LightingSummary,
111}
112
113/// Building storey data
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct StoreyData {
116    pub id: u64,
117    pub name: String,
118    pub elevation: f64,
119}
120
121/// Summary of lighting data
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct LightingSummary {
124    pub total_fixtures: usize,
125    pub total_light_sources: usize,
126    pub fixtures_per_storey: FxHashMap<String, usize>,
127    pub fixture_types_used: Vec<String>,
128    /// Total luminous flux if available
129    pub total_luminous_flux: Option<f64>,
130}
131
132/// Extract lighting data from an IFC model
133pub fn extract_lighting_data(resolver: &dyn EntityResolver) -> LightingExport {
134    let mut export = LightingExport {
135        schema: String::new(),
136        project_name: None,
137        building_name: None,
138        storeys: Vec::new(),
139        light_fixtures: Vec::new(),
140        light_fixture_types: Vec::new(),
141        summary: LightingSummary {
142            total_fixtures: 0,
143            total_light_sources: 0,
144            fixtures_per_storey: FxHashMap::default(),
145            fixture_types_used: Vec::new(),
146            total_luminous_flux: None,
147        },
148    };
149
150    // Extract project info
151    let projects = resolver.entities_by_type(&IfcType::IfcProject);
152    if let Some(project) = projects.first() {
153        export.project_name = project.get_string(2).map(|s| s.to_string());
154    }
155
156    // Extract building info
157    let buildings = resolver.entities_by_type(&IfcType::IfcBuilding);
158    if let Some(building) = buildings.first() {
159        export.building_name = building.get_string(2).map(|s| s.to_string());
160    }
161
162    // Extract storeys
163    let storeys = resolver.entities_by_type(&IfcType::IfcBuildingStorey);
164    for storey in storeys {
165        let name = storey
166            .get_string(2)
167            .map(|s| s.to_string())
168            .unwrap_or_default();
169        let elevation = storey.get_float(9).unwrap_or(0.0);
170        export.storeys.push(StoreyData {
171            id: storey.id.0 as u64,
172            name,
173            elevation,
174        });
175    }
176
177    // Extract light fixture types
178    let fixture_types = resolver.entities_by_type(&IfcType::IfcLightFixtureType);
179    for fixture_type in fixture_types {
180        let type_data = extract_fixture_type(&fixture_type);
181        export.light_fixture_types.push(type_data);
182    }
183
184    // Extract light fixtures
185    let fixtures = resolver.entities_by_type(&IfcType::IfcLightFixture);
186    let mut total_flux: f64 = 0.0;
187    let mut has_flux = false;
188
189    for fixture in fixtures {
190        let fixture_data = extract_fixture(&fixture, resolver);
191
192        // Update summary
193        if let Some(ref storey) = fixture_data.storey {
194            *export
195                .summary
196                .fixtures_per_storey
197                .entry(storey.clone())
198                .or_insert(0) += 1;
199        }
200
201        for source in &fixture_data.light_sources {
202            if let Some(flux) = source.luminous_flux {
203                total_flux += flux;
204                has_flux = true;
205            }
206        }
207
208        export.summary.total_light_sources += fixture_data.light_sources.len();
209        export.light_fixtures.push(fixture_data);
210    }
211
212    export.summary.total_fixtures = export.light_fixtures.len();
213    if has_flux {
214        export.summary.total_luminous_flux = Some(total_flux);
215    }
216
217    // Collect unique fixture types used
218    for fixture in &export.light_fixtures {
219        if let Some(ref ft) = fixture.fixture_type {
220            if let Some(ref name) = ft.name {
221                if !export.summary.fixture_types_used.contains(name) {
222                    export.summary.fixture_types_used.push(name.clone());
223                }
224            }
225        }
226    }
227
228    export
229}
230
231/// Extract data from a light fixture type entity
232fn extract_fixture_type(entity: &DecodedEntity) -> LightFixtureTypeData {
233    LightFixtureTypeData {
234        id: entity.id.0 as u64,
235        name: entity.get_string(2).map(|s| s.to_string()),
236        description: entity.get_string(3).map(|s| s.to_string()),
237        predefined_type: entity.get_enum(9).map(|s| s.to_string()),
238    }
239}
240
241/// Extract data from a light fixture entity
242fn extract_fixture(entity: &DecodedEntity, resolver: &dyn EntityResolver) -> LightFixtureData {
243    let global_id = entity.get_string(0).map(|s| s.to_string());
244    let name = entity.get_string(2).map(|s| s.to_string());
245    let description = entity.get_string(3).map(|s| s.to_string());
246    let object_type = entity.get_string(4).map(|s| s.to_string());
247
248    // Get position from placement
249    let position = extract_position(entity, resolver);
250
251    // Get fixture type
252    let fixture_type = entity.get_ref(5).and_then(|type_ref| {
253        resolver
254            .get(type_ref)
255            .map(|type_entity| extract_fixture_type(&type_entity))
256    });
257
258    // Extract light sources associated with this fixture
259    // In IFC, light sources are typically referenced through the representation
260    let light_sources = extract_light_sources(entity, resolver);
261
262    LightFixtureData {
263        id: entity.id.0 as u64,
264        global_id,
265        name,
266        description,
267        object_type,
268        position,
269        storey: None, // Could be populated from spatial containment
270        storey_elevation: None,
271        fixture_type,
272        light_sources,
273        properties: FxHashMap::default(),
274    }
275}
276
277/// Extract position from entity placement
278fn extract_position(entity: &DecodedEntity, resolver: &dyn EntityResolver) -> (f64, f64, f64) {
279    // ObjectPlacement is typically at index 5 for IfcProduct
280    let placement_ref = match entity.get_ref(5) {
281        Some(id) => id,
282        None => return (0.0, 0.0, 0.0),
283    };
284
285    let placement = match resolver.get(placement_ref) {
286        Some(p) => p,
287        None => return (0.0, 0.0, 0.0),
288    };
289
290    // IfcLocalPlacement has RelativePlacement at index 1
291    if placement.ifc_type == IfcType::IfcLocalPlacement {
292        if let Some(rel_placement_ref) = placement.get_ref(1) {
293            if let Some(axis_placement) = resolver.get(rel_placement_ref) {
294                return extract_cartesian_point(&axis_placement, resolver);
295            }
296        }
297    }
298
299    (0.0, 0.0, 0.0)
300}
301
302/// Extract coordinates from axis placement
303fn extract_cartesian_point(
304    axis_placement: &DecodedEntity,
305    resolver: &dyn EntityResolver,
306) -> (f64, f64, f64) {
307    // IfcAxis2Placement3D has Location at index 0
308    let point_ref = match axis_placement.get_ref(0) {
309        Some(id) => id,
310        None => return (0.0, 0.0, 0.0),
311    };
312
313    let point = match resolver.get(point_ref) {
314        Some(p) => p,
315        None => return (0.0, 0.0, 0.0),
316    };
317
318    if point.ifc_type == IfcType::IfcCartesianPoint {
319        // Coordinates are in a list at index 0
320        if let Some(coords) = point.get_list(0) {
321            let x = coords.first().and_then(|v| v.as_float()).unwrap_or(0.0);
322            let y = coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0);
323            let z = coords.get(2).and_then(|v| v.as_float()).unwrap_or(0.0);
324            return (x, y, z);
325        }
326    }
327
328    (0.0, 0.0, 0.0)
329}
330
331/// Extract light sources from a fixture via IFCRELASSIGNSTOGROUP
332///
333/// GLDF-exported IFC files use IFCRELASSIGNSTOGROUP to link light sources
334/// to their parent fixture. The relationship has:
335/// - Attribute 5: RelatedObjects (list of light sources)
336/// - Attribute 6: RelatingGroup (the fixture entity)
337fn extract_light_sources(
338    fixture: &DecodedEntity,
339    resolver: &dyn EntityResolver,
340) -> Vec<LightSourceData> {
341    let fixture_id = fixture.id;
342
343    // Find IFCRELASSIGNSTOGROUP where RelatingGroup (attr 6) points to this fixture
344    let group_rels = resolver.entities_by_type(&IfcType::IfcRelAssignsToGroup);
345    for rel in &group_rels {
346        if let Some(group_ref) = rel.get_ref(6) {
347            if group_ref == fixture_id {
348                // Found the group assignment for this fixture
349                if let Some(related_list) = rel.get_list(5) {
350                    return related_list
351                        .iter()
352                        .filter_map(|item| {
353                            if let AttributeValue::EntityRef(source_id) = item {
354                                resolver.get(*source_id).and_then(|source| {
355                                    if source.ifc_type == IfcType::IfcLightSourceGoniometric {
356                                        Some(extract_goniometric_source(&source, resolver))
357                                    } else {
358                                        None
359                                    }
360                                })
361                            } else {
362                                None
363                            }
364                        })
365                        .collect();
366                }
367            }
368        }
369    }
370
371    // Fallback: walk fixture's Representation → ShapeRepresentations to find
372    // IfcLightSourceGoniometric (used by Relux-exported IFC files which embed
373    // goniometric sources directly in the fixture's representation geometry).
374    let mut sources = Vec::new();
375
376    // IfcProduct.Representation is at index 6
377    if let Some(pds_id) = fixture.get_ref(6) {
378        if let Some(pds) = resolver.get(pds_id) {
379            // IfcProductDefinitionShape.Representations at index 2
380            if let Some(rep_refs) = pds.get_refs(2) {
381                for rep_id in rep_refs {
382                    if let Some(rep) = resolver.get(rep_id) {
383                        // ShapeRepresentation.Items at index 3
384                        find_goniometric_in_items(&rep, resolver, &mut sources);
385                    }
386                }
387            }
388        }
389    }
390
391    // Also check type's RepresentationMaps (for instanced fixtures)
392    if sources.is_empty() {
393        let type_rels = resolver.entities_by_type(&IfcType::IfcRelDefinesByType);
394        for rel in &type_rels {
395            if let Some(related_list) = rel.get_list(4) {
396                let is_related = related_list
397                    .iter()
398                    .any(|v| v.as_entity_ref() == Some(fixture_id));
399                if is_related {
400                    if let Some(type_id) = rel.get_ref(5) {
401                        if let Some(type_entity) = resolver.get(type_id) {
402                            // IfcTypeProduct.RepresentationMaps at index 6
403                            if let Some(map_refs) = type_entity.get_refs(6) {
404                                for map_id in map_refs {
405                                    if let Some(map) = resolver.get(map_id) {
406                                        // RepresentationMap.MappedRepresentation at index 1
407                                        if let Some(mapped_rep_id) = map.get_ref(1) {
408                                            if let Some(mapped_rep) = resolver.get(mapped_rep_id) {
409                                                find_goniometric_in_items(
410                                                    &mapped_rep,
411                                                    resolver,
412                                                    &mut sources,
413                                                );
414                                            }
415                                        }
416                                    }
417                                }
418                            }
419                        }
420                    }
421                }
422            }
423        }
424    }
425
426    sources
427}
428
429/// Recursively find IfcLightSourceGoniometric in representation items (direct or via MappedItem)
430fn find_goniometric_in_items(
431    rep: &DecodedEntity,
432    resolver: &dyn EntityResolver,
433    sources: &mut Vec<LightSourceData>,
434) {
435    let item_refs = match rep.get_refs(3) {
436        Some(refs) => refs,
437        None => return,
438    };
439
440    for item_id in item_refs {
441        if let Some(item) = resolver.get(item_id) {
442            match item.ifc_type {
443                IfcType::IfcLightSourceGoniometric => {
444                    sources.push(extract_goniometric_source(&item, resolver));
445                }
446                IfcType::IfcMappedItem => {
447                    // Follow MappedItem → RepresentationMap → ShapeRepresentation
448                    if let Some(map_id) = item.get_ref(0) {
449                        if let Some(map) = resolver.get(map_id) {
450                            if let Some(mapped_rep_id) = map.get_ref(1) {
451                                if let Some(mapped_rep) = resolver.get(mapped_rep_id) {
452                                    find_goniometric_in_items(&mapped_rep, resolver, sources);
453                                }
454                            }
455                        }
456                    }
457                }
458                _ => {}
459            }
460        }
461    }
462}
463
464/// Extract data from a goniometric light source
465fn extract_goniometric_source(
466    entity: &DecodedEntity,
467    resolver: &dyn EntityResolver,
468) -> LightSourceData {
469    // IfcLightSourceGoniometric attributes:
470    // 0: Name
471    // 1: LightColour (IfcColourRgb)
472    // 2: AmbientIntensity
473    // 3: Intensity
474    // 4: Position (IfcAxis2Placement3D)
475    // 5: ColourAppearance (IfcColourRgb)
476    // 6: ColourTemperature
477    // 7: LuminousFlux
478    // 8: LightEmissionSource
479    // 9: LightDistributionDataSource
480
481    let color_temperature = entity.get_float(6);
482    let luminous_flux = entity.get_float(7);
483    let emission_source = entity.get_enum(8).map(|s| s.to_string());
484    let intensity = entity.get_float(3);
485
486    // Extract color RGB
487    let color_rgb = entity.get_ref(1).and_then(|color_ref| {
488        resolver.get(color_ref).and_then(|color| {
489            let r = color.get_float(1)?;
490            let g = color.get_float(2)?;
491            let b = color.get_float(3)?;
492            Some((r, g, b))
493        })
494    });
495
496    // Extract distribution data
497    let distribution = entity.get_ref(9).and_then(|dist_ref| {
498        resolver
499            .get(dist_ref)
500            .map(|dist| extract_distribution(&dist, resolver))
501    });
502
503    LightSourceData {
504        id: entity.id.0 as u64,
505        source_type: "GONIOMETRIC".to_string(),
506        color_temperature,
507        luminous_flux,
508        emission_source,
509        intensity,
510        color_rgb,
511        distribution,
512    }
513}
514
515/// Extract light intensity distribution data
516fn extract_distribution(
517    entity: &DecodedEntity,
518    resolver: &dyn EntityResolver,
519) -> LightDistributionData {
520    // IfcLightIntensityDistribution attributes:
521    // 0: LightDistributionCurve (enum: TYPE_A, TYPE_B, TYPE_C)
522    // 1: DistributionData (list of IfcLightDistributionData)
523
524    let distribution_type = entity
525        .get_enum(0)
526        .map(|s| s.to_string())
527        .unwrap_or_else(|| "TYPE_C".to_string());
528
529    let mut planes = Vec::new();
530
531    if let Some(data_list) = entity.get_list(1) {
532        for data_item in data_list {
533            if let AttributeValue::EntityRef(data_ref) = data_item {
534                if let Some(data_entity) = resolver.get(*data_ref) {
535                    // IfcLightDistributionData:
536                    // 0: MainPlaneAngle
537                    // 1: SecondaryPlaneAngle (list)
538                    // 2: LuminousIntensity (list)
539
540                    let main_angle = data_entity.get_float(0).unwrap_or(0.0);
541                    let mut intensities = Vec::new();
542
543                    if let (Some(angles), Some(values)) =
544                        (data_entity.get_list(1), data_entity.get_list(2))
545                    {
546                        for (angle, value) in angles.iter().zip(values.iter()) {
547                            let a = angle.as_float().unwrap_or(0.0);
548                            let v = value.as_float().unwrap_or(0.0);
549                            intensities.push((a, v));
550                        }
551                    }
552
553                    planes.push(DistributionPlane {
554                        main_angle,
555                        intensities,
556                    });
557                }
558            }
559        }
560    }
561
562    LightDistributionData {
563        distribution_type,
564        planes,
565    }
566}
567
568/// Convert a `LightSourceData` (from IFC goniometric extraction) to an `Eulumdat` struct.
569///
570/// Returns `None` if the source has no distribution data or no distribution planes.
571pub fn light_source_to_eulumdat(source: &LightSourceData) -> Option<eulumdat::Eulumdat> {
572    let dist = source.distribution.as_ref()?;
573    if dist.planes.is_empty() {
574        return None;
575    }
576
577    let c_angles: Vec<f64> = dist.planes.iter().map(|p| p.main_angle).collect();
578    let g_angles: Vec<f64> = dist.planes[0].intensities.iter().map(|(a, _)| *a).collect();
579    let flux = source.luminous_flux.unwrap_or(0.0);
580    let temp = source.color_temperature.unwrap_or(0.0);
581    let emitter = source.emission_source.clone().unwrap_or_default();
582
583    let intensities: Vec<Vec<f64>> = dist
584        .planes
585        .iter()
586        .map(|p| p.intensities.iter().map(|(_, v)| *v).collect())
587        .collect();
588
589    Some(build_eulumdat(
590        String::new(),
591        &c_angles,
592        &g_angles,
593        flux,
594        temp,
595        &emitter,
596        &intensities,
597    ))
598}
599
600/// Convenience wrapper: convert `LightSourceData` → LDT string.
601///
602/// Returns `None` if the source has no distribution data.
603pub fn light_source_to_ldt(source: &LightSourceData) -> Option<String> {
604    light_source_to_eulumdat(source).map(|ldt| ldt.to_ldt())
605}
606
607/// Convert a `GoniometricData` (from `PropertyReader::goniometric_sources()`) to `Eulumdat`.
608pub fn goniometric_to_eulumdat(src: &bimifc_model::GoniometricData) -> eulumdat::Eulumdat {
609    let c_angles: Vec<f64> = src.planes.iter().map(|p| p.c_angle).collect();
610    let g_angles: Vec<f64> = if src.planes.is_empty() {
611        Vec::new()
612    } else {
613        src.planes[0].gamma_angles.clone()
614    };
615
616    let intensities: Vec<Vec<f64>> = src.planes.iter().map(|p| p.intensities.clone()).collect();
617
618    build_eulumdat(
619        src.name.clone(),
620        &c_angles,
621        &g_angles,
622        src.luminous_flux,
623        src.colour_temperature,
624        &src.emitter_type,
625        &intensities,
626    )
627}
628
629/// Convenience wrapper: convert `GoniometricData` → LDT string.
630pub fn goniometric_to_ldt(src: &bimifc_model::GoniometricData) -> String {
631    goniometric_to_eulumdat(src).to_ldt()
632}
633
634/// Shared helper that builds an `Eulumdat` struct from raw photometric parameters.
635fn build_eulumdat(
636    name: String,
637    c_angles: &[f64],
638    g_angles: &[f64],
639    luminous_flux: f64,
640    colour_temperature: f64,
641    emitter_type: &str,
642    intensities_cd: &[Vec<f64>],
643) -> eulumdat::Eulumdat {
644    use eulumdat::{Eulumdat, LampSet, Symmetry};
645
646    let mut ldt = Eulumdat::new();
647    ldt.luminaire_name = name;
648
649    ldt.c_angles = c_angles.to_vec();
650    ldt.g_angles = g_angles.to_vec();
651    ldt.num_c_planes = ldt.c_angles.len();
652    ldt.num_g_planes = ldt.g_angles.len();
653
654    if ldt.num_c_planes > 1 {
655        ldt.c_plane_distance = ldt.c_angles[1] - ldt.c_angles[0];
656    }
657    if ldt.num_g_planes > 1 {
658        ldt.g_plane_distance = ldt.g_angles[1] - ldt.g_angles[0];
659    }
660
661    // Determine symmetry from C-plane coverage
662    let max_c = ldt.c_angles.last().copied().unwrap_or(0.0);
663    ldt.symmetry = if max_c <= 1.0 {
664        Symmetry::VerticalAxis
665    } else if max_c <= 91.0 {
666        Symmetry::BothPlanes
667    } else if max_c <= 181.0 {
668        Symmetry::PlaneC0C180
669    } else {
670        Symmetry::None
671    };
672
673    // Convert absolute cd → cd/klm by dividing by (flux/1000)
674    let flux_factor = if luminous_flux > 0.0 {
675        luminous_flux / 1000.0
676    } else {
677        1.0
678    };
679
680    ldt.intensities = intensities_cd
681        .iter()
682        .map(|plane| plane.iter().map(|&v| v / flux_factor).collect())
683        .collect();
684
685    // Lamp set
686    let cct_str = if colour_temperature > 0.0 {
687        format!("{:.0}K", colour_temperature)
688    } else {
689        String::new()
690    };
691    ldt.lamp_sets.push(LampSet {
692        num_lamps: 1,
693        lamp_type: emitter_type.to_string(),
694        total_luminous_flux: luminous_flux,
695        color_appearance: cct_str,
696        color_rendering_group: String::new(),
697        wattage_with_ballast: 0.0,
698    });
699
700    ldt.conversion_factor = flux_factor;
701
702    ldt
703}
704
705/// Export lighting data to JSON format compatible with gldf-ifc-viewer
706pub fn export_to_json(export: &LightingExport) -> String {
707    serde_json::to_string_pretty(export).unwrap_or_else(|_| "{}".to_string())
708}
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713    use bimifc_model::IfcParser;
714
715    #[test]
716    fn test_light_fixture_data_serialization() {
717        let fixture = LightFixtureData {
718            id: 123,
719            global_id: Some("abc-def".to_string()),
720            name: Some("Test Fixture".to_string()),
721            description: None,
722            object_type: None,
723            position: (1.0, 2.0, 3.0),
724            storey: Some("Ground Floor".to_string()),
725            storey_elevation: Some(0.0),
726            fixture_type: None,
727            light_sources: vec![],
728            properties: FxHashMap::default(),
729        };
730
731        let json = serde_json::to_string(&fixture).unwrap();
732        assert!(json.contains("Test Fixture"));
733        assert!(json.contains("123"));
734    }
735
736    #[test]
737    fn test_gldf_light_sources_per_fixture() {
738        // Test that extract_light_sources correctly uses IFCRELASSIGNSTOGROUP
739        // to assign sources to their specific fixture (not all sources to all fixtures)
740        let gldf_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
741            .parent()
742            .unwrap()
743            .parent()
744            .unwrap()
745            .join("ifc/gldf-exported.ifc");
746
747        if !gldf_path.exists() {
748            // Skip if file not available
749            return;
750        }
751
752        let content = std::fs::read_to_string(&gldf_path).unwrap();
753        let parser = crate::StepParser::new();
754        let model = parser.parse(&content).unwrap();
755        let export = extract_lighting_data(model.resolver());
756
757        // GLDF file has 3 fixtures, each with exactly 2 light sources
758        assert_eq!(export.light_fixtures.len(), 3);
759        for fixture in &export.light_fixtures {
760            eprintln!(
761                "Fixture #{} '{}': {} sources",
762                fixture.id,
763                fixture.name.as_deref().unwrap_or("?"),
764                fixture.light_sources.len()
765            );
766            for s in &fixture.light_sources {
767                eprintln!(
768                    "  Source #{}: flux={:?} dist={}",
769                    s.id,
770                    s.luminous_flux,
771                    s.distribution
772                        .as_ref()
773                        .map(|d| format!("{} planes", d.planes.len()))
774                        .unwrap_or_else(|| "none".to_string())
775                );
776            }
777            assert_eq!(
778                fixture.light_sources.len(),
779                2,
780                "Fixture '{}' (#{}) should have 2 sources, got {}",
781                fixture.name.as_deref().unwrap_or("?"),
782                fixture.id,
783                fixture.light_sources.len()
784            );
785        }
786        // Total: 6 sources (not 18 from the old bug)
787        assert_eq!(export.summary.total_light_sources, 6);
788    }
789
790    #[test]
791    fn test_relux_light_sources_via_representation() {
792        // Test that extract_light_sources falls back to representation walking
793        // for Relux-exported IFC files (no IfcRelAssignsToGroup)
794        let relux_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
795            .parent()
796            .unwrap()
797            .parent()
798            .unwrap()
799            .join("ifc/relux-test.ifc");
800
801        if !relux_path.exists() {
802            return;
803        }
804
805        let content = std::fs::read_to_string(&relux_path).unwrap();
806        let parser = crate::StepParser::new();
807        let model = parser.parse(&content).unwrap();
808        let export = extract_lighting_data(model.resolver());
809
810        eprintln!("Relux fixtures: {}", export.light_fixtures.len());
811        for fixture in &export.light_fixtures {
812            eprintln!(
813                "Fixture #{} '{}': {} sources",
814                fixture.id,
815                fixture.name.as_deref().unwrap_or("?"),
816                fixture.light_sources.len()
817            );
818            for s in &fixture.light_sources {
819                eprintln!(
820                    "  Source #{}: flux={:?} dist={}",
821                    s.id,
822                    s.luminous_flux,
823                    s.distribution
824                        .as_ref()
825                        .map(|d| format!("{} ({} planes)", d.distribution_type, d.planes.len()))
826                        .unwrap_or_else(|| "none".to_string())
827                );
828            }
829        }
830
831        // Relux file should have fixtures with light sources found via representation
832        assert!(
833            !export.light_fixtures.is_empty(),
834            "Expected light fixtures in relux-test.ifc"
835        );
836        let total_sources: usize = export
837            .light_fixtures
838            .iter()
839            .map(|f| f.light_sources.len())
840            .sum();
841        assert!(
842            total_sources > 0,
843            "Expected light sources in relux-test.ifc fixtures"
844        );
845    }
846}