Skip to main content

gldf_rs/ifc/
ifc_import.rs

1//! IFC to GLDF Import
2//!
3//! Converts IFC luminaire data to GLDF format.
4//! Extracts:
5//! - IFCLIGHTFIXTURETYPE → GLDF Variants
6//! - Property sets → GLDF attributes
7//! - IFCLIGHTSOURCEGONIOMETRIC → Light sources
8//! - IFCLIGHTINTENSITYDISTRIBUTION → EULUMDAT photometry
9//! - IFCSHAPEREPRESENTATION → L3D geometry
10
11use super::step_parser::{StepParser, StepValue};
12use anyhow::{anyhow, Result};
13
14/// Extracted luminaire data from IFC
15#[derive(Debug, Clone, Default, PartialEq)]
16pub struct ImportedLuminaire {
17    /// Product name
18    pub name: String,
19    /// Description
20    pub description: Option<String>,
21    /// Manufacturer name
22    pub manufacturer: Option<String>,
23    /// Model/article number
24    pub model_reference: Option<String>,
25    /// Variants (one per IFCLIGHTFIXTURETYPE)
26    pub variants: Vec<ImportedVariant>,
27    /// Product-level descriptive attributes (IP code, safety class, etc.)
28    pub descriptive_attributes: ProductDescriptiveAttributes,
29    /// Embedded files (images, sensors, etc.) for roundtrip preservation
30    pub embedded_files: Vec<EmbeddedFile>,
31}
32
33/// Product-level descriptive attributes (shared across all variants)
34#[derive(Debug, Clone, Default, PartialEq)]
35pub struct ProductDescriptiveAttributes {
36    /// Electrical safety class (I, II, III)
37    pub electrical_safety_class: Option<String>,
38    /// IP code (e.g., IP20, IP65)
39    pub ip_code: Option<String>,
40    /// Median useful life (e.g., "L80B50 70000h 25°C")
41    pub median_useful_life: Option<String>,
42}
43
44/// Embedded file from GLDF (image, sensor, etc.)
45#[derive(Debug, Clone, PartialEq)]
46pub struct EmbeddedFile {
47    /// File type ("image", "sensor", etc.)
48    pub file_type: String,
49    /// Original filename
50    pub filename: String,
51    /// Content type (MIME type, e.g., "image/png", "sensor/sensldt")
52    pub content_type: String,
53    /// File content as bytes
54    pub content: Vec<u8>,
55}
56
57/// Extracted variant data
58#[derive(Debug, Clone, Default, PartialEq)]
59pub struct ImportedVariant {
60    /// Variant name (from type name)
61    pub name: String,
62    /// Description
63    pub description: Option<String>,
64    /// GLDF variant ID (if present from Pset_LuminaireVariant)
65    pub gldf_variant_id: Option<String>,
66    /// Light fixture type enum
67    pub fixture_type: Option<String>,
68    /// Light sources in this variant
69    pub light_sources: Vec<ImportedLightSource>,
70    /// Photometry data (if embedded)
71    pub photometry: Option<ImportedPhotometry>,
72    /// Geometry mesh data
73    pub geometry: Option<ImportedGeometry>,
74    /// Properties
75    pub properties: ImportedProperties,
76}
77
78/// Extracted light source data
79#[derive(Debug, Clone, Default, PartialEq)]
80pub struct ImportedLightSource {
81    /// Source name
82    pub name: String,
83    /// Correlated color temperature (K)
84    pub color_temperature: Option<f64>,
85    /// Luminous flux (lm)
86    pub luminous_flux: Option<f64>,
87    /// Emission source type (LED, FLUORESCENT, etc.)
88    pub emission_source: Option<String>,
89    /// Light intensity distribution reference
90    pub distribution: Option<ImportedDistribution>,
91    /// Original photometry filename (e.g., "narrow.ldt") - preserved through IFC roundtrip
92    pub photometry_filename: Option<String>,
93    /// Original LDT metadata preserved through IFC roundtrip
94    pub ldt_metadata: Option<LdtMetadata>,
95}
96
97/// LDT file metadata for roundtrip preservation
98#[derive(Debug, Clone, Default, PartialEq)]
99pub struct LdtMetadata {
100    /// Symmetry type (0=none, 1=vertical axis, 2=C0-C180, 3=C90-C270, 4=both planes)
101    pub symmetry: i32,
102    /// Number of C-planes in original file
103    pub num_c_planes: i32,
104    /// Number of gamma angles in original file
105    pub num_g_angles: i32,
106    /// C-plane step (Dc)
107    pub dc: f64,
108    /// Gamma step (Dg)
109    pub dg: f64,
110    /// Total luminous flux from LDT header
111    pub total_flux: Option<f64>,
112    /// Direct ratios (DR) values
113    pub dr: Option<[f64; 10]>,
114    /// Luminaire name from LDT
115    pub luminaire_name: Option<String>,
116    /// Manufacturer from LDT
117    pub manufacturer: Option<String>,
118    /// Complete raw LDT content (for exact roundtrip)
119    pub raw_content: Option<String>,
120}
121
122/// Light intensity distribution data
123#[derive(Debug, Clone, Default, PartialEq)]
124pub struct ImportedDistribution {
125    /// Distribution type (TYPE_A, TYPE_B, TYPE_C)
126    pub distribution_type: String,
127    /// Distribution data: (main_angle, secondary_angles, intensities)
128    pub data: Vec<DistributionPlane>,
129}
130
131/// Single plane of distribution data (C-plane for Type C)
132#[derive(Debug, Clone, Default, PartialEq)]
133pub struct DistributionPlane {
134    /// Main plane angle (C-angle for Type C)
135    pub main_angle: f64,
136    /// Intensities at secondary angles (gamma angles)
137    pub intensities: Vec<(f64, f64)>, // (angle, intensity)
138}
139
140/// Extracted photometry in EULUMDAT-compatible format
141#[derive(Debug, Clone, Default, PartialEq)]
142pub struct ImportedPhotometry {
143    /// Luminaire name
144    pub name: String,
145    /// Total luminous flux
146    pub luminous_flux: f64,
147    /// Number of C-planes
148    pub num_c_planes: usize,
149    /// Number of gamma angles per plane
150    pub num_gamma_angles: usize,
151    /// C-plane angles
152    pub c_angles: Vec<f64>,
153    /// Gamma angles
154    pub gamma_angles: Vec<f64>,
155    /// Intensity values \[c_plane\]\[gamma\]
156    pub intensities: Vec<Vec<f64>>,
157}
158
159/// Extracted mesh geometry
160#[derive(Debug, Clone, Default, PartialEq)]
161pub struct ImportedGeometry {
162    /// Vertex positions (x, y, z) in meters
163    pub vertices: Vec<(f64, f64, f64)>,
164    /// Triangle indices (0-based)
165    pub triangles: Vec<(u32, u32, u32)>,
166}
167
168/// Extracted properties from property sets
169#[derive(Debug, Clone, Default, PartialEq)]
170pub struct ImportedProperties {
171    /// Number of light sources
172    pub number_of_sources: Option<i32>,
173    /// Total wattage (W)
174    pub total_wattage: Option<f64>,
175    /// Mounting type (CEILING, WALL, etc.)
176    pub mounting_type: Option<String>,
177    /// Color temperature (K)
178    pub color_temperature: Option<f64>,
179    /// Luminous flux (lm)
180    pub luminous_flux: Option<f64>,
181    /// Power (W)
182    pub power: Option<f64>,
183    /// Efficacy (lm/W)
184    pub efficacy: Option<f64>,
185    /// Color rendering index
186    pub cri: Option<i32>,
187    /// Weight (kg)
188    pub weight: Option<f64>,
189    /// IP code (IEC 60529)
190    pub ip_code: Option<String>,
191    /// IK code (IEC 62262) - mechanical impact protection
192    pub ik_code: Option<String>,
193    /// Rated voltage (V)
194    pub rated_voltage: Option<f64>,
195    /// Maximum rated voltage (V) - for voltage range
196    pub rated_voltage_max: Option<f64>,
197    /// Rated current (A)
198    pub rated_current: Option<f64>,
199    /// Power factor (cos φ)
200    pub power_factor: Option<f64>,
201    /// Nominal frequency min (Hz)
202    pub nominal_frequency_min: Option<f64>,
203    /// Nominal frequency max (Hz)
204    pub nominal_frequency_max: Option<f64>,
205    /// Number of electrical poles
206    pub number_of_poles: Option<i32>,
207    /// Has protective earth connection
208    pub has_protective_earth: Option<bool>,
209    /// Insulation standard class (CLASS0, CLASSI, CLASSII, CLASSIII)
210    pub insulation_standard_class: Option<String>,
211    /// Heat dissipation (W)
212    pub heat_dissipation: Option<f64>,
213    /// Nominal power consumption (W)
214    pub nominal_power_consumption: Option<f64>,
215    /// Number of power supply ports
216    pub number_of_power_supply_ports: Option<i32>,
217    /// Electrical safety class (I, II, III) - legacy/GLDF format
218    pub electrical_safety_class: Option<String>,
219    /// Median useful life (e.g., "L80B50 70000h 25°C")
220    pub median_useful_life: Option<String>,
221    /// GLDF mounting type (Ceiling, Wall, Ground, etc.)
222    pub gldf_mounting_type: Option<String>,
223    /// Recessed depth (mm)
224    pub recessed_depth: Option<f64>,
225    /// Maintenance factor (0.0-1.0)
226    pub maintenance_factor: Option<f64>,
227}
228
229/// IFC to GLDF importer
230pub struct IfcImporter {
231    parser: StepParser,
232}
233
234impl IfcImporter {
235    /// Create importer from IFC file content
236    #[allow(clippy::should_implement_trait)]
237    pub fn from_str(content: &str) -> Result<Self> {
238        let parser = StepParser::parse(content)?;
239        Ok(Self { parser })
240    }
241
242    /// Import all luminaires from the IFC file
243    pub fn import(&self) -> Result<ImportedLuminaire> {
244        let mut luminaire = ImportedLuminaire::default();
245
246        // Find all light fixture types
247        let fixture_types = self.parser.find_by_type("IFCLIGHTFIXTURETYPE");
248
249        if fixture_types.is_empty() {
250            return Err(anyhow!("No IFCLIGHTFIXTURETYPE found in IFC file"));
251        }
252
253        // Extract manufacturer info from first type
254        if let Some(first_type) = fixture_types.first() {
255            let params = first_type.get_params();
256
257            // Name is param[2]
258            if let Some(name) = params.get(2).and_then(|v| v.as_string()) {
259                // Split variant suffix if present (e.g., "Product - Variant")
260                if let Some(pos) = name.find(" - ") {
261                    luminaire.name = name[..pos].to_string();
262                } else {
263                    luminaire.name = name.to_string();
264                }
265            }
266
267            // Description is param[3]
268            if let Some(desc) = params.get(3).and_then(|v| v.as_string()) {
269                luminaire.description = Some(desc.to_string());
270            }
271
272            // Get manufacturer from property set
273            if let Some(pset) =
274                self.find_property_set(first_type.id, "Pset_ManufacturerTypeInformation")
275            {
276                if let Some(mfr) = self.get_property_value(&pset, "Manufacturer") {
277                    luminaire.manufacturer = mfr.as_string().map(|s| s.to_string());
278                }
279                if let Some(model) = self.get_property_value(&pset, "ModelReference") {
280                    luminaire.model_reference = model.as_string().map(|s| s.to_string());
281                }
282            }
283        }
284
285        // Extract each fixture type as a variant
286        for fixture_type in &fixture_types {
287            let variant = self.extract_variant(fixture_type)?;
288            luminaire.variants.push(variant);
289        }
290
291        // Extract product-level descriptive attributes from first variant
292        // (IP code, safety class, etc. are product-level in GLDF, not per-variant)
293        if let Some(first_variant) = luminaire.variants.first() {
294            luminaire.descriptive_attributes.electrical_safety_class =
295                first_variant.properties.electrical_safety_class.clone();
296            luminaire.descriptive_attributes.ip_code = first_variant.properties.ip_code.clone();
297            luminaire.descriptive_attributes.median_useful_life =
298                first_variant.properties.median_useful_life.clone();
299        }
300
301        // Extract embedded files (images, sensors) from Pset_GLDF_EmbeddedFile
302        luminaire.embedded_files = self.extract_embedded_files();
303
304        Ok(luminaire)
305    }
306
307    /// Extract embedded GLDF files (images, sensors) from IFC property sets
308    fn extract_embedded_files(&self) -> Vec<EmbeddedFile> {
309        use base64::{engine::general_purpose::STANDARD, Engine};
310
311        let mut files = Vec::new();
312
313        // Find all Pset_GLDF_EmbeddedFile property sets
314        for entity in self.parser.find_by_type("IFCPROPERTYSET") {
315            let params = entity.get_params();
316            if let Some(name) = params.get(2).and_then(|v| v.as_string()) {
317                if name == "Pset_GLDF_EmbeddedFile" {
318                    // Extract properties from this pset
319                    let mut file_type = None;
320                    let mut filename = None;
321                    let mut content_type = None;
322                    let mut content_b64 = None;
323
324                    if let Some(StepValue::List(props)) = params.get(4) {
325                        for prop_ref in props {
326                            if let StepValue::Ref(prop_id) = prop_ref {
327                                if let Some(prop_entity) = self.parser.get(*prop_id) {
328                                    let prop_params = prop_entity.get_params();
329                                    if let Some(prop_name) =
330                                        prop_params.first().and_then(|v: &StepValue| v.as_string())
331                                    {
332                                        let value = prop_params
333                                            .get(2)
334                                            .and_then(|v: &StepValue| v.as_string());
335                                        match prop_name {
336                                            "GLDF_FileType" => file_type = value.map(String::from),
337                                            "GLDF_Filename" => filename = value.map(String::from),
338                                            "GLDF_ContentType" => {
339                                                content_type = value.map(String::from)
340                                            }
341                                            "GLDF_FileContent" => {
342                                                content_b64 = value.map(String::from)
343                                            }
344                                            _ => {}
345                                        }
346                                    }
347                                }
348                            }
349                        }
350                    }
351
352                    // If we have all required fields, decode and add the file
353                    if let (Some(ft), Some(fn_), Some(ct), Some(b64)) =
354                        (file_type, filename, content_type, content_b64)
355                    {
356                        if let Ok(content) = STANDARD.decode(&b64) {
357                            files.push(EmbeddedFile {
358                                file_type: ft,
359                                filename: fn_,
360                                content_type: ct,
361                                content,
362                            });
363                        }
364                    }
365                }
366            }
367        }
368
369        files
370    }
371
372    /// Extract variant data from IFCLIGHTFIXTURETYPE
373    fn extract_variant(
374        &self,
375        fixture_type: &super::step_parser::StepEntity,
376    ) -> Result<ImportedVariant> {
377        let mut variant = ImportedVariant::default();
378        let params = fixture_type.get_params();
379
380        // Name (param[2])
381        if let Some(name) = params.get(2).and_then(|v| v.as_string()) {
382            variant.name = name.to_string();
383        }
384
385        // Description (param[3])
386        if let Some(desc) = params.get(3).and_then(|v| v.as_string()) {
387            variant.description = Some(desc.to_string());
388        }
389
390        // Predefined type (param[9])
391        if let Some(ptype) = params.get(9).and_then(|v| v.as_enum()) {
392            variant.fixture_type = Some(ptype.to_string());
393        }
394
395        // Extract properties from Pset_LightFixtureTypeCommon
396        if let Some(pset) = self.find_property_set(fixture_type.id, "Pset_LightFixtureTypeCommon") {
397            if let Some(v) = self.get_property_value(&pset, "NumberOfSources") {
398                variant.properties.number_of_sources = v.as_integer().map(|i| i as i32);
399            }
400            if let Some(v) = self.get_property_value(&pset, "TotalWattage") {
401                variant.properties.total_wattage = v.as_real();
402            }
403            if let Some(v) = self.get_property_value(&pset, "LightFixtureMountingType") {
404                variant.properties.mounting_type = v.as_string().map(|s| s.to_string());
405            }
406        }
407
408        // Extract variant-specific properties from Pset_LuminaireVariant
409        if let Some(pset) = self.find_property_set(fixture_type.id, "Pset_LuminaireVariant") {
410            if let Some(v) = self.get_property_value(&pset, "GLDF_VariantId") {
411                variant.gldf_variant_id = v.as_string().map(|s| s.to_string());
412            }
413            if let Some(v) = self.get_property_value(&pset, "ColorTemperature") {
414                variant.properties.color_temperature = v.as_real();
415            }
416            if let Some(v) = self.get_property_value(&pset, "LuminousFlux") {
417                variant.properties.luminous_flux = v.as_real();
418            }
419            if let Some(v) = self.get_property_value(&pset, "Power") {
420                variant.properties.power = v.as_real();
421            }
422            if let Some(v) = self.get_property_value(&pset, "Efficacy") {
423                variant.properties.efficacy = v.as_real();
424            }
425            if let Some(v) = self.get_property_value(&pset, "ColorRenderingIndex") {
426                variant.properties.cri = v.as_integer().map(|i| i as i32);
427            }
428            if let Some(v) = self.get_property_value(&pset, "Weight") {
429                variant.properties.weight = v.as_real();
430            }
431        }
432
433        // Extract electrical properties from Pset_ElectricalDeviceCommon (Electrical MVD)
434        if let Some(pset) = self.find_property_set(fixture_type.id, "Pset_ElectricalDeviceCommon") {
435            // IP_Code (IEC 60529)
436            if let Some(v) = self.get_property_value(&pset, "IP_Code") {
437                variant.properties.ip_code = v.as_string().map(|s| s.to_string());
438            }
439            // Legacy IPCode support
440            if variant.properties.ip_code.is_none() {
441                if let Some(v) = self.get_property_value(&pset, "IPCode") {
442                    variant.properties.ip_code = v.as_string().map(|s| s.to_string());
443                }
444            }
445            // IK_Code (IEC 62262)
446            if let Some(v) = self.get_property_value(&pset, "IK_Code") {
447                variant.properties.ik_code = v.as_string().map(|s| s.to_string());
448            }
449            // RatedVoltage
450            if let Some(v) = self.get_property_value(&pset, "RatedVoltage") {
451                variant.properties.rated_voltage = v.as_real();
452                // TODO: Handle bounded values for voltage range
453            }
454            // RatedCurrent
455            if let Some(v) = self.get_property_value(&pset, "RatedCurrent") {
456                variant.properties.rated_current = v.as_real();
457            }
458            // PowerFactor
459            if let Some(v) = self.get_property_value(&pset, "PowerFactor") {
460                variant.properties.power_factor = v.as_real();
461            }
462            // NominalFrequencyRange - bounded value
463            if let Some(v) = self.get_property_value(&pset, "NominalFrequencyRange") {
464                // For bounded values, try to extract both min and max
465                if let Some(freq) = v.as_real() {
466                    variant.properties.nominal_frequency_min = Some(freq);
467                    variant.properties.nominal_frequency_max = Some(freq);
468                }
469            }
470            // NumberOfPoles
471            if let Some(v) = self.get_property_value(&pset, "NumberOfPoles") {
472                variant.properties.number_of_poles = v.as_integer().map(|i| i as i32);
473            }
474            // HasProtectiveEarth
475            if let Some(v) = self.get_property_value(&pset, "HasProtectiveEarth") {
476                variant.properties.has_protective_earth = v.as_boolean();
477            }
478            // InsulationStandardClass - can be enum or string
479            if let Some(v) = self.get_property_value(&pset, "InsulationStandardClass") {
480                variant.properties.insulation_standard_class = v
481                    .as_enum()
482                    .map(|s| s.to_string())
483                    .or_else(|| v.as_string().map(|s| s.to_string()));
484            }
485            // HeatDissipation
486            if let Some(v) = self.get_property_value(&pset, "HeatDissipation") {
487                variant.properties.heat_dissipation = v.as_real();
488            }
489            // Power
490            if let Some(v) = self.get_property_value(&pset, "Power") {
491                // Don't overwrite power from variant pset
492                if variant.properties.power.is_none() {
493                    variant.properties.power = v.as_real();
494                }
495            }
496            // NominalPowerConsumption
497            if let Some(v) = self.get_property_value(&pset, "NominalPowerConsumption") {
498                variant.properties.nominal_power_consumption = v.as_real();
499            }
500            // NumberOfPowerSupplyPorts
501            if let Some(v) = self.get_property_value(&pset, "NumberOfPowerSupplyPorts") {
502                variant.properties.number_of_power_supply_ports = v.as_integer().map(|i| i as i32);
503            }
504        }
505
506        // Extract from Pset_LightFixtureTypeCommon
507        if let Some(pset) = self.find_property_set(fixture_type.id, "Pset_LightFixtureTypeCommon") {
508            if let Some(v) = self.get_property_value(&pset, "MaintenanceFactor") {
509                variant.properties.maintenance_factor = v.as_real();
510            }
511            // These might override values from basic pset
512            if variant.properties.number_of_sources.is_none() {
513                if let Some(v) = self.get_property_value(&pset, "NumberOfSources") {
514                    variant.properties.number_of_sources = v.as_integer().map(|i| i as i32);
515                }
516            }
517            if variant.properties.total_wattage.is_none() {
518                if let Some(v) = self.get_property_value(&pset, "TotalWattage") {
519                    variant.properties.total_wattage = v.as_real();
520                }
521            }
522        }
523
524        // Extract GLDF descriptive attributes from ALL Pset_GLDF_DescriptiveAttributes property sets
525        for pset in self.find_all_property_sets(fixture_type.id, "Pset_GLDF_DescriptiveAttributes")
526        {
527            if let Some(v) = self.get_property_value(&pset, "ElectricalSafetyClass") {
528                variant.properties.electrical_safety_class = v.as_string().map(|s| s.to_string());
529            }
530            if let Some(v) = self.get_property_value(&pset, "MedianUsefulLife") {
531                variant.properties.median_useful_life = v.as_string().map(|s| s.to_string());
532            }
533            if let Some(v) = self.get_property_value(&pset, "GLDF_MountingType") {
534                variant.properties.gldf_mounting_type = v.as_string().map(|s| s.to_string());
535            }
536            if let Some(v) = self.get_property_value(&pset, "GLDF_RecessedDepth") {
537                variant.properties.recessed_depth = v.as_real();
538            }
539        }
540
541        // Extract photometry filenames from Pset_GLDF_PhotometryFiles (for roundtrip preservation)
542        let photometry_filenames = self.extract_photometry_filenames(fixture_type.id);
543
544        // Extract LDT metadata from Pset_GLDF_LDTMetadata (for roundtrip preservation)
545        let ldt_metadata_map = self.extract_ldt_metadata(fixture_type.id);
546
547        // Extract raw LDT content from Pset_GLDF_LDTRawContent (for exact roundtrip)
548        let ldt_raw_content_map = self.extract_ldt_raw_content(fixture_type.id);
549
550        // Find associated light sources via IFCRELASSIGNSTOGROUP
551        let light_sources = self.find_light_sources_for_fixture(fixture_type.id);
552        for (idx, ls_id) in light_sources.iter().enumerate() {
553            if let Some(mut ls) = self.extract_light_source(*ls_id) {
554                // Associate preserved photometry filename with this light source
555                if let Some(filename) = photometry_filenames.get(idx) {
556                    ls.photometry_filename = Some(filename.clone());
557                }
558                // Associate preserved LDT metadata with this light source
559                // First try raw content, then fall back to metadata
560                if let Some((filename, content)) = ldt_raw_content_map.get(&(idx + 1)) {
561                    let mut meta = ldt_metadata_map
562                        .get(&(idx + 1))
563                        .cloned()
564                        .unwrap_or_default();
565                    meta.raw_content = Some(content.clone());
566                    // Override filename if we have it from raw content
567                    ls.photometry_filename = Some(filename.clone());
568                    ls.ldt_metadata = Some(meta);
569                } else if let Some(meta) = ldt_metadata_map.get(&(idx + 1)) {
570                    ls.ldt_metadata = Some(meta.clone());
571                }
572                variant.light_sources.push(ls);
573            }
574        }
575
576        // Find geometry from fixture occurrence
577        if let Some(occurrence_id) = self.find_fixture_occurrence(fixture_type.id) {
578            if let Some(geom) = self.extract_geometry(occurrence_id) {
579                variant.geometry = Some(geom);
580            }
581        }
582
583        Ok(variant)
584    }
585
586    /// Extract photometry filenames from Pset_GLDF_PhotometryFiles
587    fn extract_photometry_filenames(&self, element_id: u64) -> Vec<String> {
588        let mut filenames = Vec::new();
589
590        if let Some(pset_id) = self.find_property_set(element_id, "Pset_GLDF_PhotometryFiles") {
591            // Get all PhotometryFile_N properties (1, 2, 3, etc.)
592            for i in 1..=100 {
593                let prop_name = format!("PhotometryFile_{}", i);
594                if let Some(value) = self.get_property_value(&pset_id, &prop_name) {
595                    if let Some(filename) = value.as_string() {
596                        filenames.push(filename.to_string());
597                    }
598                } else {
599                    break; // No more properties
600                }
601            }
602        }
603
604        filenames
605    }
606
607    /// Extract LDT metadata from all Pset_GLDF_LDTMetadata property sets
608    fn extract_ldt_metadata(
609        &self,
610        element_id: u64,
611    ) -> std::collections::HashMap<usize, LdtMetadata> {
612        use std::collections::HashMap;
613        let mut metadata_map: HashMap<usize, LdtMetadata> = HashMap::new();
614
615        // Find ALL Pset_GLDF_LDTMetadata property sets for this element
616        for pset_id in self.find_all_property_sets(element_id, "Pset_GLDF_LDTMetadata") {
617            // Each property set contains metadata for one LDT file
618            // Try to find the index from properties like LDT_1_Index, LDT_2_Index, etc.
619            for i in 1..=100 {
620                let idx_name = format!("LDT_{}_Index", i);
621                // Check if this index exists in this pset
622                if self.get_property_value(&pset_id, &idx_name).is_none() {
623                    continue; // Try next index
624                }
625
626                let mut meta = LdtMetadata::default();
627
628                // Symmetry
629                if let Some(v) = self.get_property_value(&pset_id, &format!("LDT_{}_Symmetry", i)) {
630                    meta.symmetry = v.as_integer().unwrap_or(0) as i32;
631                }
632
633                // Number of C-planes
634                if let Some(v) = self.get_property_value(&pset_id, &format!("LDT_{}_NumCPlanes", i))
635                {
636                    meta.num_c_planes = v.as_integer().unwrap_or(1) as i32;
637                }
638
639                // Number of gamma angles
640                if let Some(v) = self.get_property_value(&pset_id, &format!("LDT_{}_NumGAngles", i))
641                {
642                    meta.num_g_angles = v.as_integer().unwrap_or(19) as i32;
643                }
644
645                // C-plane distance (Dc)
646                if let Some(v) = self.get_property_value(&pset_id, &format!("LDT_{}_Dc", i)) {
647                    meta.dc = v.as_real().unwrap_or(0.0);
648                }
649
650                // Gamma distance (Dg)
651                if let Some(v) = self.get_property_value(&pset_id, &format!("LDT_{}_Dg", i)) {
652                    meta.dg = v.as_real().unwrap_or(0.0);
653                }
654
655                // Total flux
656                if let Some(v) = self.get_property_value(&pset_id, &format!("LDT_{}_TotalFlux", i))
657                {
658                    meta.total_flux = Some(v.as_real().unwrap_or(1000.0));
659                }
660
661                // DR values (stored as comma-separated string)
662                if let Some(v) = self.get_property_value(&pset_id, &format!("LDT_{}_DR", i)) {
663                    if let Some(dr_str) = v.as_string() {
664                        let dr_vals: Vec<f64> = dr_str
665                            .split(',')
666                            .filter_map(|s| s.trim().parse().ok())
667                            .collect();
668                        if dr_vals.len() == 10 {
669                            let mut dr_arr = [1.0; 10];
670                            for (j, val) in dr_vals.iter().enumerate() {
671                                dr_arr[j] = *val;
672                            }
673                            meta.dr = Some(dr_arr);
674                        }
675                    }
676                }
677
678                // Luminaire name
679                if let Some(v) =
680                    self.get_property_value(&pset_id, &format!("LDT_{}_LuminaireName", i))
681                {
682                    meta.luminaire_name = v.as_string().map(|s| s.to_string());
683                }
684
685                metadata_map.insert(i, meta);
686            }
687        }
688
689        metadata_map
690    }
691
692    /// Extract raw LDT content from Pset_GLDF_LDTRawContent
693    fn extract_ldt_raw_content(
694        &self,
695        element_id: u64,
696    ) -> std::collections::HashMap<usize, (String, String)> {
697        use std::collections::HashMap;
698        let mut content_map: HashMap<usize, (String, String)> = HashMap::new();
699
700        // Find ALL Pset_GLDF_LDTRawContent property sets for this element
701        for pset_id in self.find_all_property_sets(element_id, "Pset_GLDF_LDTRawContent") {
702            // Each property set contains LDT_N_Filename and LDT_N_Content
703            for i in 1..=100 {
704                let filename_prop = format!("LDT_{}_Filename", i);
705                let content_prop = format!("LDT_{}_Content", i);
706
707                let filename = self
708                    .get_property_value(&pset_id, &filename_prop)
709                    .and_then(|v| v.as_string().map(|s| s.to_string()));
710                let content_b64 = self
711                    .get_property_value(&pset_id, &content_prop)
712                    .and_then(|v| v.as_string().map(|s| s.to_string()));
713
714                if let (Some(f), Some(c)) = (filename, content_b64) {
715                    // Decode base64 content
716                    use base64::{engine::general_purpose::STANDARD, Engine};
717                    if let Ok(decoded) = STANDARD.decode(&c) {
718                        if let Ok(content) = String::from_utf8(decoded) {
719                            content_map.insert(i, (f, content));
720                        }
721                    }
722                }
723            }
724        }
725
726        content_map
727    }
728
729    /// Find ALL property sets with a given name for an element
730    fn find_all_property_sets(&self, element_id: u64, pset_name: &str) -> Vec<u64> {
731        let mut psets = Vec::new();
732
733        // Look for IFCRELDEFINESBYPROPERTIES that references our element
734        for entity in self.parser.find_by_type("IFCRELDEFINESBYPROPERTIES") {
735            let params = entity.get_params();
736
737            // RelatedObjects is param[4] (list)
738            let related_to_element = params
739                .get(4)
740                .and_then(|v| v.as_list())
741                .is_some_and(|list| list.iter().any(|item| item.as_ref() == Some(element_id)));
742
743            if !related_to_element {
744                continue;
745            }
746
747            // RelatingPropertyDefinition is param[5] (ref)
748            if let Some(pset_ref) = params.get(5).and_then(|v| v.as_ref()) {
749                if let Some(pset) = self.parser.get(pset_ref) {
750                    if pset.entity_type == "IFCPROPERTYSET" {
751                        let pset_params = pset.get_params();
752                        // Name is param[2]
753                        if let Some(name) = pset_params.get(2).and_then(|v| v.as_string()) {
754                            if name == pset_name {
755                                psets.push(pset_ref);
756                            }
757                        }
758                    }
759                }
760            }
761        }
762
763        psets
764    }
765
766    /// Find property set for an element
767    fn find_property_set(&self, element_id: u64, pset_name: &str) -> Option<u64> {
768        // Look for IFCRELDEFINESBYPROPERTIES that references our element
769        for entity in self.parser.find_by_type("IFCRELDEFINESBYPROPERTIES") {
770            let params = entity.get_params();
771
772            // RelatedObjects is param[4] (list of element refs)
773            if let Some(related) = params.get(4).and_then(|v| v.as_list()) {
774                let has_element = related.iter().any(|v| v.as_ref() == Some(element_id));
775
776                if has_element {
777                    // RelatingPropertyDefinition is param[5]
778                    if let Some(pset_ref) = params.get(5).and_then(|v| v.as_ref()) {
779                        if let Some(pset) = self.parser.get(pset_ref) {
780                            if pset.entity_type == "IFCPROPERTYSET" {
781                                let pset_params = pset.get_params();
782                                // Name is param[2]
783                                if let Some(name) = pset_params.get(2).and_then(|v| v.as_string()) {
784                                    if name == pset_name {
785                                        return Some(pset_ref);
786                                    }
787                                }
788                            }
789                        }
790                    }
791                }
792            }
793        }
794        None
795    }
796
797    /// Get property value from property set
798    fn get_property_value(&self, pset_id: &u64, property_name: &str) -> Option<StepValue> {
799        let pset = self.parser.get(*pset_id)?;
800        let params = pset.get_params();
801
802        // HasProperties is param[4] (list of property refs)
803        let properties = params.get(4)?.as_list()?;
804
805        for prop_ref in properties {
806            if let Some(prop_id) = prop_ref.as_ref() {
807                if let Some(prop) = self.parser.get(prop_id) {
808                    if prop.entity_type == "IFCPROPERTYSINGLEVALUE" {
809                        let prop_params = prop.get_params();
810                        // Name is param[0]
811                        if let Some(name) = prop_params.first().and_then(|v| v.as_string()) {
812                            if name == property_name {
813                                // NominalValue is param[2]
814                                return prop_params.get(2).cloned();
815                            }
816                        }
817                    }
818                }
819            }
820        }
821        None
822    }
823
824    /// Find light sources associated with a fixture type via IFCRELASSIGNSTOGROUP
825    fn find_light_sources_for_fixture(&self, fixture_type_id: u64) -> Vec<u64> {
826        let mut sources = Vec::new();
827
828        // First find fixture occurrences of this type
829        let occurrences = self.find_occurrences_of_type(fixture_type_id);
830
831        for occ_id in occurrences {
832            // Find IFCRELASSIGNSTOGROUP that has this occurrence as RelatingGroup
833            for entity in self.parser.find_by_type("IFCRELASSIGNSTOGROUP") {
834                let params = entity.get_params();
835
836                // RelatingGroup is param[6]
837                if params.get(6).and_then(|v| v.as_ref()) == Some(occ_id) {
838                    // RelatedObjects is param[5] (list)
839                    if let Some(related) = params.get(5).and_then(|v| v.as_list()) {
840                        for item in related {
841                            if let Some(id) = item.as_ref() {
842                                if let Some(e) = self.parser.get(id) {
843                                    if e.entity_type == "IFCLIGHTSOURCEGONIOMETRIC"
844                                        || e.entity_type == "IFCLIGHTSOURCESPOT"
845                                        || e.entity_type == "IFCLIGHTSOURCEPOSITIONAL"
846                                    {
847                                        sources.push(id);
848                                    }
849                                }
850                            }
851                        }
852                    }
853                }
854            }
855        }
856
857        // Only fall back to finding ALL light sources if no grouped sources were found
858        // This handles legacy IFC files without proper grouping
859        if sources.is_empty() {
860            for entity in self.parser.find_by_type("IFCLIGHTSOURCEGONIOMETRIC") {
861                sources.push(entity.id);
862            }
863        }
864
865        sources
866    }
867
868    /// Find fixture occurrences (IFCLIGHTFIXTURE) of a given type
869    fn find_occurrences_of_type(&self, type_id: u64) -> Vec<u64> {
870        let mut occurrences = Vec::new();
871
872        // Find IFCRELDEFINESBYTYPE
873        for entity in self.parser.find_by_type("IFCRELDEFINESBYTYPE") {
874            let params = entity.get_params();
875
876            // RelatingType is param[5]
877            if params.get(5).and_then(|v| v.as_ref()) == Some(type_id) {
878                // RelatedObjects is param[4] (list)
879                if let Some(related) = params.get(4).and_then(|v| v.as_list()) {
880                    for item in related {
881                        if let Some(id) = item.as_ref() {
882                            occurrences.push(id);
883                        }
884                    }
885                }
886            }
887        }
888
889        occurrences
890    }
891
892    /// Find a fixture occurrence for a type
893    fn find_fixture_occurrence(&self, type_id: u64) -> Option<u64> {
894        self.find_occurrences_of_type(type_id).first().copied()
895    }
896
897    /// Extract light source data
898    fn extract_light_source(&self, light_source_id: u64) -> Option<ImportedLightSource> {
899        let entity = self.parser.get(light_source_id)?;
900        let params = entity.get_params();
901
902        let mut source = ImportedLightSource::default();
903
904        // IFCLIGHTSOURCEGONIOMETRIC params:
905        // 0: Name, 1: Description, 2: LightColour, 3: AmbientIntensity,
906        // 4: Position, 5: ColourAppearance, 6: ColourTemperature, 7: LuminousFlux,
907        // 8: LightEmissionSource, 9: LightDistributionDataSource
908
909        // Name (param[0])
910        if let Some(name) = params.first().and_then(|v| v.as_string()) {
911            source.name = name.to_string();
912        }
913
914        // ColorTemperature (param[6])
915        if let Some(cct) = params.get(6).and_then(|v| v.as_real()) {
916            source.color_temperature = Some(cct);
917        }
918
919        // LuminousFlux (param[7])
920        if let Some(flux) = params.get(7).and_then(|v| v.as_real()) {
921            source.luminous_flux = Some(flux);
922        }
923
924        // LightEmissionSource (param[8])
925        if let Some(emission) = params.get(8).and_then(|v| v.as_enum()) {
926            source.emission_source = Some(emission.to_string());
927        }
928
929        // LightDistributionDataSource (param[9])
930        if let Some(dist_ref) = params.get(9).and_then(|v| v.as_ref()) {
931            // First check if it's an IFCEXTERNALREFERENCE (contains filename)
932            if let Some(ext_ref) = self.parser.get(dist_ref) {
933                if ext_ref.entity_type == "IFCEXTERNALREFERENCE" {
934                    let ext_params = ext_ref.get_params();
935                    // Location is param[0] - contains the file path
936                    if let Some(location) = ext_params.first().and_then(|v| v.as_string()) {
937                        // Extract just the filename from the path
938                        let filename = location.rsplit('/').next().unwrap_or(location);
939                        source.photometry_filename = Some(filename.to_string());
940                    }
941                }
942            }
943            // Then try to extract distribution data
944            source.distribution = self.extract_distribution(dist_ref);
945        }
946
947        Some(source)
948    }
949
950    /// Extract light distribution data
951    fn extract_distribution(&self, dist_id: u64) -> Option<ImportedDistribution> {
952        let entity = self.parser.get(dist_id)?;
953
954        // Could be IFCLIGHTINTENSITYDISTRIBUTION or IFCEXTERNALREFERENCE
955        if entity.entity_type == "IFCLIGHTINTENSITYDISTRIBUTION" {
956            let params = entity.get_params();
957            let mut dist = ImportedDistribution::default();
958
959            // LightDistributionCurve (param[0]) - enum
960            if let Some(curve_type) = params.first().and_then(|v| v.as_enum()) {
961                dist.distribution_type = curve_type.to_string();
962            }
963
964            // DistributionData (param[1]) - list of IFCLIGHTDISTRIBUTIONDATA refs
965            if let Some(data_list) = params.get(1).and_then(|v| v.as_list()) {
966                for data_ref in data_list {
967                    if let Some(data_id) = data_ref.as_ref() {
968                        if let Some(plane) = self.extract_distribution_plane(data_id) {
969                            dist.data.push(plane);
970                        }
971                    }
972                }
973            }
974
975            return Some(dist);
976        }
977
978        None
979    }
980
981    /// Extract a single distribution plane
982    fn extract_distribution_plane(&self, data_id: u64) -> Option<DistributionPlane> {
983        let entity = self.parser.get(data_id)?;
984        if entity.entity_type != "IFCLIGHTDISTRIBUTIONDATA" {
985            return None;
986        }
987
988        let params = entity.get_params();
989        let mut plane = DistributionPlane::default();
990
991        // MainPlaneAngle (param[0])
992        if let Some(main) = params.first().and_then(|v| v.as_real()) {
993            plane.main_angle = main;
994        }
995
996        // SecondaryPlaneAngle (param[1]) - list
997        // LuminousIntensity (param[2]) - list
998        if let Some(secondary_list) = params.get(1).and_then(|v| v.as_list()) {
999            let intensity_list = params.get(2).and_then(|v| v.as_list());
1000
1001            for (i, angle_val) in secondary_list.iter().enumerate() {
1002                if let Some(angle) = angle_val.as_real() {
1003                    let intensity = intensity_list
1004                        .and_then(|l| l.get(i))
1005                        .and_then(|v| v.as_real())
1006                        .unwrap_or(0.0);
1007                    plane.intensities.push((angle, intensity));
1008                }
1009            }
1010        }
1011
1012        Some(plane)
1013    }
1014
1015    /// Extract geometry from fixture occurrence
1016    fn extract_geometry(&self, occurrence_id: u64) -> Option<ImportedGeometry> {
1017        let entity = self.parser.get(occurrence_id)?;
1018        let params = entity.get_params();
1019
1020        // Representation is param[6] for IFCLIGHTFIXTURE
1021        let rep_ref = params.get(6).and_then(|v| v.as_ref())?;
1022        let rep = self.parser.get(rep_ref)?;
1023
1024        if rep.entity_type != "IFCPRODUCTDEFINITIONSHAPE" {
1025            return None;
1026        }
1027
1028        let rep_params = rep.get_params();
1029        // Representations is param[2] (list)
1030        let reps = rep_params.get(2)?.as_list()?;
1031
1032        for rep_item in reps {
1033            if let Some(shape_rep_id) = rep_item.as_ref() {
1034                if let Some(geom) = self.extract_shape_representation(shape_rep_id) {
1035                    return Some(geom);
1036                }
1037            }
1038        }
1039
1040        None
1041    }
1042
1043    /// Extract geometry from IFCSHAPEREPRESENTATION
1044    fn extract_shape_representation(&self, rep_id: u64) -> Option<ImportedGeometry> {
1045        let rep = self.parser.get(rep_id)?;
1046        if rep.entity_type != "IFCSHAPEREPRESENTATION" {
1047            return None;
1048        }
1049
1050        let params = rep.get_params();
1051        // RepresentationType is param[2]
1052        let rep_type = params.get(2).and_then(|v| v.as_string())?;
1053
1054        // Items is param[3] (list)
1055        let items = params.get(3)?.as_list()?;
1056
1057        // Handle different representation types
1058        match rep_type {
1059            "Tessellation" => {
1060                // Look for IFCTRIANGULATEDFACESET
1061                for item in items {
1062                    if let Some(item_id) = item.as_ref() {
1063                        if let Some(geom) = self.extract_triangulated_faceset(item_id) {
1064                            return Some(geom);
1065                        }
1066                    }
1067                }
1068            }
1069            "MappedRepresentation" => {
1070                // Look for IFCMAPPEDITEM and follow to the actual geometry
1071                for item in items {
1072                    if let Some(item_id) = item.as_ref() {
1073                        if let Some(geom) = self.extract_mapped_item(item_id) {
1074                            return Some(geom);
1075                        }
1076                    }
1077                }
1078            }
1079            "SweptSolid" => {
1080                // IFCEXTRUDEDAREASOLID - create simple box mesh
1081                for item in items {
1082                    if let Some(item_id) = item.as_ref() {
1083                        if let Some(geom) = self.extract_extruded_solid(item_id) {
1084                            return Some(geom);
1085                        }
1086                    }
1087                }
1088            }
1089            _ => {}
1090        }
1091
1092        None
1093    }
1094
1095    /// Extract geometry from IFCMAPPEDITEM
1096    fn extract_mapped_item(&self, item_id: u64) -> Option<ImportedGeometry> {
1097        let entity = self.parser.get(item_id)?;
1098        if entity.entity_type != "IFCMAPPEDITEM" {
1099            return None;
1100        }
1101
1102        let params = entity.get_params();
1103        // MappingSource is param[0] - ref to IFCREPRESENTATIONMAP
1104        let map_ref = params.first().and_then(|v| v.as_ref())?;
1105        let rep_map = self.parser.get(map_ref)?;
1106
1107        if rep_map.entity_type != "IFCREPRESENTATIONMAP" {
1108            return None;
1109        }
1110
1111        let map_params = rep_map.get_params();
1112        // MappedRepresentation is param[1] - ref to IFCSHAPEREPRESENTATION
1113        let shape_rep_ref = map_params.get(1).and_then(|v| v.as_ref())?;
1114
1115        // Recursively extract from the mapped shape representation
1116        self.extract_shape_representation(shape_rep_ref)
1117    }
1118
1119    /// Extract mesh from IFCTRIANGULATEDFACESET
1120    fn extract_triangulated_faceset(&self, faceset_id: u64) -> Option<ImportedGeometry> {
1121        let entity = self.parser.get(faceset_id)?;
1122        if entity.entity_type != "IFCTRIANGULATEDFACESET" {
1123            return None;
1124        }
1125
1126        let params = entity.get_params();
1127        let mut geom = ImportedGeometry::default();
1128
1129        // Coordinates is param[0] - ref to IFCCARTESIANPOINTLIST3D
1130        if let Some(coords_ref) = params.first().and_then(|v| v.as_ref()) {
1131            if let Some(coords) = self.parser.get(coords_ref) {
1132                if coords.entity_type == "IFCCARTESIANPOINTLIST3D" {
1133                    let coords_params = coords.get_params();
1134                    // CoordList is param[0] - nested list of coordinates
1135                    if let Some(coord_list) = coords_params.first().and_then(|v| v.as_list()) {
1136                        for point in coord_list {
1137                            if let Some(point_coords) = point.as_list() {
1138                                let x = point_coords
1139                                    .first()
1140                                    .and_then(|v| v.as_real())
1141                                    .unwrap_or(0.0);
1142                                let y =
1143                                    point_coords.get(1).and_then(|v| v.as_real()).unwrap_or(0.0);
1144                                let z =
1145                                    point_coords.get(2).and_then(|v| v.as_real()).unwrap_or(0.0);
1146                                geom.vertices.push((x, y, z));
1147                            }
1148                        }
1149                    }
1150                }
1151            }
1152        }
1153
1154        // CoordIndex is param[3] - list of triangle indices (1-based in IFC)
1155        if let Some(index_list) = params.get(3).and_then(|v| v.as_list()) {
1156            for tri in index_list {
1157                if let Some(indices) = tri.as_list() {
1158                    let i0 = indices.first().and_then(|v| v.as_integer()).unwrap_or(1) as u32 - 1;
1159                    let i1 = indices.get(1).and_then(|v| v.as_integer()).unwrap_or(1) as u32 - 1;
1160                    let i2 = indices.get(2).and_then(|v| v.as_integer()).unwrap_or(1) as u32 - 1;
1161                    geom.triangles.push((i0, i1, i2));
1162                }
1163            }
1164        }
1165
1166        if geom.vertices.is_empty() || geom.triangles.is_empty() {
1167            return None;
1168        }
1169
1170        Some(geom)
1171    }
1172
1173    /// Extract mesh from IFCEXTRUDEDAREASOLID (create simple box)
1174    fn extract_extruded_solid(&self, solid_id: u64) -> Option<ImportedGeometry> {
1175        let entity = self.parser.get(solid_id)?;
1176        if entity.entity_type != "IFCEXTRUDEDAREASOLID" {
1177            return None;
1178        }
1179
1180        let params = entity.get_params();
1181
1182        // SweptArea is param[0] - ref to profile
1183        let profile_ref = params.first().and_then(|v| v.as_ref())?;
1184        let profile = self.parser.get(profile_ref)?;
1185
1186        // Depth is param[3]
1187        let depth = params.get(3).and_then(|v| v.as_real()).unwrap_or(0.1);
1188
1189        // Handle IFCRECTANGLEPROFILEDEF
1190        if profile.entity_type == "IFCRECTANGLEPROFILEDEF" {
1191            let profile_params = profile.get_params();
1192            let width = profile_params
1193                .get(3)
1194                .and_then(|v| v.as_real())
1195                .unwrap_or(0.3);
1196            let height = profile_params
1197                .get(4)
1198                .and_then(|v| v.as_real())
1199                .unwrap_or(0.3);
1200
1201            return Some(self.create_box_mesh(width, height, depth));
1202        }
1203
1204        None
1205    }
1206
1207    /// Create a simple box mesh
1208    fn create_box_mesh(&self, width: f64, height: f64, depth: f64) -> ImportedGeometry {
1209        let hw = width / 2.0;
1210        let hh = height / 2.0;
1211
1212        let vertices = vec![
1213            // Bottom face
1214            (-hw, -hh, 0.0),
1215            (hw, -hh, 0.0),
1216            (hw, hh, 0.0),
1217            (-hw, hh, 0.0),
1218            // Top face
1219            (-hw, -hh, depth),
1220            (hw, -hh, depth),
1221            (hw, hh, depth),
1222            (-hw, hh, depth),
1223        ];
1224
1225        let triangles = vec![
1226            // Bottom
1227            (0, 2, 1),
1228            (0, 3, 2),
1229            // Top
1230            (4, 5, 6),
1231            (4, 6, 7),
1232            // Front
1233            (0, 1, 5),
1234            (0, 5, 4),
1235            // Back
1236            (2, 3, 7),
1237            (2, 7, 6),
1238            // Left
1239            (0, 4, 7),
1240            (0, 7, 3),
1241            // Right
1242            (1, 2, 6),
1243            (1, 6, 5),
1244        ];
1245
1246        ImportedGeometry {
1247            vertices,
1248            triangles,
1249        }
1250    }
1251}
1252
1253#[cfg(test)]
1254mod tests {
1255    use super::*;
1256
1257    #[test]
1258    fn test_import_basic() {
1259        let content = r#"
1260ISO-10303-21;
1261HEADER;
1262FILE_DESCRIPTION(('GLDF to IFC Export'),'2;1');
1263FILE_NAME('test.ifc','2024-01-01',(''),(''),'gldf-rs','gldf-rs','');
1264FILE_SCHEMA(('IFC4'));
1265ENDSEC;
1266
1267DATA;
1268#1=IFCPERSON($,$,'',$,$,$,$,$);
1269#2=IFCORGANIZATION($,'Test Corp','GLDF Export',$,$);
1270#3=IFCPERSONANDORGANIZATION(#1,#2,$);
1271#4=IFCAPPLICATION(#2,'1.0','Test','Test');
1272#5=IFCOWNERHISTORY(#3,#4,.READWRITE.,.ADDED.,$,$,$,0);
1273#6=IFCLIGHTFIXTURETYPE('guid1',#5,'LED Panel - 4000K','Test fixture',$,$,$,$,$,.POINTSOURCE.);
1274#7=IFCPROPERTYSINGLEVALUE('Manufacturer',$,IFCLABEL('Test Corp'),$);
1275#8=IFCPROPERTYSINGLEVALUE('ModelReference',$,IFCLABEL('LED-001'),$);
1276#9=IFCPROPERTYSET('guid2',#5,'Pset_ManufacturerTypeInformation',$,(#7,#8));
1277#10=IFCRELDEFINESBYPROPERTIES('guid3',#5,$,$,(#6),#9);
1278ENDSEC;
1279
1280END-ISO-10303-21;
1281"#;
1282
1283        let importer = IfcImporter::from_str(content).unwrap();
1284        let luminaire = importer.import().unwrap();
1285
1286        assert_eq!(luminaire.name, "LED Panel");
1287        assert_eq!(luminaire.manufacturer, Some("Test Corp".to_string()));
1288        assert_eq!(luminaire.model_reference, Some("LED-001".to_string()));
1289        assert_eq!(luminaire.variants.len(), 1);
1290        assert_eq!(luminaire.variants[0].name, "LED Panel - 4000K");
1291    }
1292}