1use bimifc_model::{AttributeValue, DecodedEntity, EntityResolver, IfcType};
11use rustc_hash::FxHashMap;
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct LightFixtureData {
17 pub id: u64,
19 pub global_id: Option<String>,
21 pub name: Option<String>,
23 pub description: Option<String>,
25 pub object_type: Option<String>,
27 pub position: (f64, f64, f64),
29 pub storey: Option<String>,
31 pub storey_elevation: Option<f64>,
33 pub fixture_type: Option<LightFixtureTypeData>,
35 pub light_sources: Vec<LightSourceData>,
37 pub properties: FxHashMap<String, PropertySetData>,
39}
40
41#[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#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct LightSourceData {
53 pub id: u64,
54 pub source_type: String,
55 pub color_temperature: Option<f64>,
57 pub luminous_flux: Option<f64>,
59 pub emission_source: Option<String>,
61 pub intensity: Option<f64>,
63 pub color_rgb: Option<(f64, f64, f64)>,
65 pub distribution: Option<LightDistributionData>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct LightDistributionData {
72 pub distribution_type: String,
74 pub planes: Vec<DistributionPlane>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct DistributionPlane {
81 pub main_angle: f64,
83 pub intensities: Vec<(f64, f64)>, }
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct PropertySetData {
90 pub name: String,
91 pub properties: FxHashMap<String, String>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct LightingExport {
97 pub schema: String,
99 pub project_name: Option<String>,
101 pub building_name: Option<String>,
103 pub storeys: Vec<StoreyData>,
105 pub light_fixtures: Vec<LightFixtureData>,
107 pub light_fixture_types: Vec<LightFixtureTypeData>,
109 pub summary: LightingSummary,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct StoreyData {
116 pub id: u64,
117 pub name: String,
118 pub elevation: f64,
119}
120
121#[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 pub total_luminous_flux: Option<f64>,
130}
131
132pub 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 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 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 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 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 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 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 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
231fn 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
241fn 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 let position = extract_position(entity, resolver);
250
251 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 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, storey_elevation: None,
271 fixture_type,
272 light_sources,
273 properties: FxHashMap::default(),
274 }
275}
276
277fn extract_position(entity: &DecodedEntity, resolver: &dyn EntityResolver) -> (f64, f64, f64) {
279 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 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
302fn extract_cartesian_point(
304 axis_placement: &DecodedEntity,
305 resolver: &dyn EntityResolver,
306) -> (f64, f64, f64) {
307 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 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
331fn extract_light_sources(
338 fixture: &DecodedEntity,
339 resolver: &dyn EntityResolver,
340) -> Vec<LightSourceData> {
341 let fixture_id = fixture.id;
342
343 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 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 let mut sources = Vec::new();
375
376 if let Some(pds_id) = fixture.get_ref(6) {
378 if let Some(pds) = resolver.get(pds_id) {
379 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 find_goniometric_in_items(&rep, resolver, &mut sources);
385 }
386 }
387 }
388 }
389 }
390
391 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 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 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
429fn 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 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
464fn extract_goniometric_source(
466 entity: &DecodedEntity,
467 resolver: &dyn EntityResolver,
468) -> LightSourceData {
469 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 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 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
515fn extract_distribution(
517 entity: &DecodedEntity,
518 resolver: &dyn EntityResolver,
519) -> LightDistributionData {
520 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 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
568pub 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
600pub fn light_source_to_ldt(source: &LightSourceData) -> Option<String> {
604 light_source_to_eulumdat(source).map(|ldt| ldt.to_ldt())
605}
606
607pub 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
629pub fn goniometric_to_ldt(src: &bimifc_model::GoniometricData) -> String {
631 goniometric_to_eulumdat(src).to_ldt()
632}
633
634fn 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 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 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 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
705pub 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 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 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 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 assert_eq!(export.summary.total_light_sources, 6);
788 }
789
790 #[test]
791 fn test_relux_light_sources_via_representation() {
792 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 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}