Skip to main content

gldf_rs/ifc/
step_writer.rs

1//! IFC STEP file writer
2//!
3//! Generates IFC files in STEP Physical File Format (ISO 10303-21)
4
5use super::types::{
6    DistributionSystemEnum, ElectricalDeviceProperties, EntityRef, FlowDirectionEnum,
7    InsulationStandardClass, LightEmissionSourceEnum, LightFixtureCommonProperties,
8    LightFixtureTypeEnum, ManufacturerTypeInfo, OptionalRef, ServiceLifeInfo, WarrantyInfo,
9};
10use std::collections::HashMap;
11
12/// Triangulated mesh data for IFC export
13///
14/// Contains vertex positions and triangle indices in a format
15/// suitable for IFC TRIANGULATEDFACESET representation.
16#[derive(Debug, Clone, Default)]
17pub struct MeshData {
18    /// Vertex positions as (x, y, z) tuples in meters
19    pub vertices: Vec<(f64, f64, f64)>,
20    /// Triangle indices (1-based for IFC)
21    /// Each tuple is (v1, v2, v3) referencing vertices
22    pub triangles: Vec<(u32, u32, u32)>,
23}
24
25#[cfg(not(target_arch = "wasm32"))]
26use chrono::{DateTime, Utc};
27
28/// IFC STEP file writer
29///
30/// Builds a valid IFC file with the minimum structure needed for light fixtures.
31pub struct StepWriter {
32    schema: String,
33    next_id: u64,
34    entities: Vec<String>,
35    // Track entity IDs for relationships
36    entity_map: HashMap<String, EntityRef>,
37    // Track light sources for connecting to fixtures
38    light_source_ids: Vec<EntityRef>,
39    // GUID counter for uniqueness
40    guid_counter: u64,
41}
42
43impl StepWriter {
44    /// Create a new STEP writer for the given IFC schema
45    pub fn new(schema: &str) -> Self {
46        Self {
47            schema: schema.to_string(),
48            next_id: 1,
49            entities: Vec::new(),
50            entity_map: HashMap::new(),
51            light_source_ids: Vec::new(),
52            guid_counter: 1,
53        }
54    }
55
56    /// Get next entity ID and increment counter
57    fn next_id(&mut self) -> EntityRef {
58        let id = EntityRef::new(self.next_id);
59        self.next_id += 1;
60        id
61    }
62
63    /// Add an entity line
64    fn add_entity(&mut self, entity: String) -> EntityRef {
65        let id = self.next_id();
66        self.entities.push(format!("{}={}", id, entity));
67        id
68    }
69
70    /// Generate a valid IFC GlobalId (22 characters, base64-like encoding)
71    ///
72    /// IFC GlobalId is a compressed UUID representation using 64 characters:
73    /// 0-9, A-Z, a-z, _ and $
74    fn generate_guid(&mut self) -> String {
75        // IFC base64 character set (NOT standard base64!)
76        const CHARS: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_$";
77
78        // Generate a pseudo-random 128-bit value
79        #[cfg(not(target_arch = "wasm32"))]
80        let seed = {
81            use std::time::{SystemTime, UNIX_EPOCH};
82            let time_part = SystemTime::now()
83                .duration_since(UNIX_EPOCH)
84                .map(|d| d.as_nanos())
85                .unwrap_or(0);
86            // Mix in counter for uniqueness within same timestamp
87            time_part.wrapping_add(self.guid_counter as u128 * 0x517cc1b727220a95)
88        };
89
90        #[cfg(target_arch = "wasm32")]
91        let seed = {
92            // Use counter-based approach for WASM
93            let base = 0x0123456789ABCDEFu128;
94            base.wrapping_mul(self.guid_counter as u128)
95                .wrapping_add(0xFEDCBA9876543210u128)
96        };
97
98        self.guid_counter += 1;
99
100        // Convert 128-bit value to 22 base-64 characters
101        // Each character encodes 6 bits, 22 * 6 = 132 bits (we use 128)
102        let mut result = String::with_capacity(22);
103        let mut n = seed;
104
105        for _ in 0..22 {
106            result.push(CHARS[(n % 64) as usize] as char);
107            n /= 64;
108        }
109
110        // Reverse to get most-significant bits first
111        result.chars().rev().collect()
112    }
113
114    /// Escape a string for STEP format
115    fn escape_string(s: &str) -> String {
116        s.replace('\\', "\\\\").replace('\'', "''")
117    }
118
119    /// Create an IFCAXIS2PLACEMENT3D entity (properly, not inline!)
120    fn add_axis2_placement_3d(
121        &mut self,
122        origin: EntityRef,
123        axis: Option<EntityRef>,
124        ref_dir: Option<EntityRef>,
125    ) -> EntityRef {
126        let axis_str = axis
127            .map(|a| a.to_string())
128            .unwrap_or_else(|| "$".to_string());
129        let ref_str = ref_dir
130            .map(|r| r.to_string())
131            .unwrap_or_else(|| "$".to_string());
132        let entity_str = format!("IFCAXIS2PLACEMENT3D({},{},{})", origin, axis_str, ref_str);
133        self.add_entity(entity_str)
134    }
135
136    /// Create an IFCLOCALPLACEMENT entity with proper references
137    fn add_local_placement(
138        &mut self,
139        relative_to: Option<EntityRef>,
140        placement: EntityRef,
141    ) -> EntityRef {
142        let relative_str = relative_to
143            .map(|r| r.to_string())
144            .unwrap_or_else(|| "$".to_string());
145        let entity_str = format!("IFCLOCALPLACEMENT({},{})", relative_str, placement);
146        self.add_entity(entity_str)
147    }
148
149    // =========================================================================
150    // Core IFC entities
151    // =========================================================================
152
153    /// Add IfcOwnerHistory
154    pub fn add_owner_history(&mut self, organization_name: &str) -> EntityRef {
155        // Person
156        let person = self.add_entity("IFCPERSON($,$,'',$,$,$,$,$)".to_string());
157
158        // Organization
159        let org_str = format!(
160            "IFCORGANIZATION($,'{}','GLDF Export',$,$)",
161            Self::escape_string(organization_name)
162        );
163        let org = self.add_entity(org_str);
164
165        // PersonAndOrganization
166        let po_str = format!("IFCPERSONANDORGANIZATION({},{},$)", person, org);
167        let person_org = self.add_entity(po_str);
168
169        // Application
170        let app_str = format!("IFCAPPLICATION({},'0.3.3','gldf-rs','gldf-rs')", org);
171        let app = self.add_entity(app_str);
172
173        // OwnerHistory
174        let oh_str = format!(
175            "IFCOWNERHISTORY({},{},{},{},$,$,$,{})",
176            person_org,
177            app,
178            ".READWRITE.",
179            ".ADDED.",
180            Self::current_timestamp()
181        );
182        self.add_entity(oh_str)
183    }
184
185    /// Add IfcProject
186    pub fn add_project(&mut self, name: &str, owner_history: EntityRef) -> EntityRef {
187        // Units
188        let si_length = self.add_entity("IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.)".to_string());
189        let si_area = self.add_entity("IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.)".to_string());
190        let si_volume = self.add_entity("IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.)".to_string());
191        let si_angle = self.add_entity("IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.)".to_string());
192        let si_lumen = self.add_entity("IFCSIUNIT(*,.LUMINOUSFLUXUNIT.,$,.LUMEN.)".to_string());
193        let si_power = self.add_entity("IFCSIUNIT(*,.POWERUNIT.,$,.WATT.)".to_string());
194
195        let ua_str = format!(
196            "IFCUNITASSIGNMENT(({},{},{},{},{},{}))",
197            si_length, si_area, si_volume, si_angle, si_lumen, si_power
198        );
199        let unit_assignment = self.add_entity(ua_str);
200
201        // Geometric context - create entities separately (Issue 1 fix)
202        let origin = self.add_entity("IFCCARTESIANPOINT((0.,0.,0.))".to_string());
203        let axis = self.add_entity("IFCDIRECTION((0.,0.,1.))".to_string());
204        let ref_dir = self.add_entity("IFCDIRECTION((1.,0.,0.))".to_string());
205        let placement = self.add_axis2_placement_3d(origin, Some(axis), Some(ref_dir));
206
207        let ctx_str = format!(
208            "IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.0E-5,{},{})",
209            placement,
210            OptionalRef::None
211        );
212        let context_3d = self.add_entity(ctx_str);
213
214        // Project
215        let guid = self.generate_guid();
216        let escaped_name = Self::escape_string(name);
217        let proj_str = format!(
218            "IFCPROJECT('{}',{},'{}','GLDF to IFC Export',$,$,$,({}),{})",
219            guid, owner_history, escaped_name, context_3d, unit_assignment
220        );
221        let project = self.add_entity(proj_str);
222
223        self.entity_map.insert("project".to_string(), project);
224        self.entity_map.insert("context_3d".to_string(), context_3d);
225        project
226    }
227
228    /// Add IfcSite
229    pub fn add_site(
230        &mut self,
231        name: &str,
232        owner_history: EntityRef,
233        project: EntityRef,
234    ) -> EntityRef {
235        // Create placement entities separately (Issue 1 fix)
236        let origin = self.add_entity("IFCCARTESIANPOINT((0.,0.,0.))".to_string());
237        let axis2_placement = self.add_axis2_placement_3d(origin, None, None);
238        let placement = self.add_local_placement(None, axis2_placement);
239
240        let guid = self.generate_guid();
241        let escaped_name = Self::escape_string(name);
242        let site_str = format!(
243            "IFCSITE('{}',{},'{}','',$,{},$,$,.ELEMENT.,$,$,$,$,$)",
244            guid, owner_history, escaped_name, placement
245        );
246        let site = self.add_entity(site_str);
247
248        // Aggregate site to project
249        let guid2 = self.generate_guid();
250        let rel_str = format!(
251            "IFCRELAGGREGATES('{}',{},$,$,{},({}))",
252            guid2, owner_history, project, site
253        );
254        self.add_entity(rel_str);
255
256        site
257    }
258
259    /// Add IfcBuilding
260    pub fn add_building(
261        &mut self,
262        name: &str,
263        owner_history: EntityRef,
264        site: EntityRef,
265    ) -> EntityRef {
266        // Create placement entities separately (Issue 1 fix)
267        let origin = self.add_entity("IFCCARTESIANPOINT((0.,0.,0.))".to_string());
268        let axis2_placement = self.add_axis2_placement_3d(origin, None, None);
269        let placement = self.add_local_placement(None, axis2_placement);
270
271        let guid = self.generate_guid();
272        let escaped_name = Self::escape_string(name);
273        let bldg_str = format!(
274            "IFCBUILDING('{}',{},'{}','',$,{},$,$,.ELEMENT.,$,$,$)",
275            guid, owner_history, escaped_name, placement
276        );
277        let building = self.add_entity(bldg_str);
278
279        // Aggregate building to site
280        let guid2 = self.generate_guid();
281        let rel_str = format!(
282            "IFCRELAGGREGATES('{}',{},$,$,{},({}))",
283            guid2, owner_history, site, building
284        );
285        self.add_entity(rel_str);
286
287        building
288    }
289
290    /// Add IfcBuildingStorey
291    pub fn add_storey(
292        &mut self,
293        name: &str,
294        owner_history: EntityRef,
295        building: EntityRef,
296    ) -> EntityRef {
297        // Create placement entities separately (Issue 1 fix)
298        let origin = self.add_entity("IFCCARTESIANPOINT((0.,0.,0.))".to_string());
299        let axis2_placement = self.add_axis2_placement_3d(origin, None, None);
300        let placement = self.add_local_placement(None, axis2_placement);
301
302        let guid = self.generate_guid();
303        let escaped_name = Self::escape_string(name);
304        let storey_str = format!(
305            "IFCBUILDINGSTOREY('{}',{},'{}','',$,{},$,$,.ELEMENT.,0.)",
306            guid, owner_history, escaped_name, placement
307        );
308        let storey = self.add_entity(storey_str);
309
310        // Aggregate storey to building
311        let guid2 = self.generate_guid();
312        let rel_str = format!(
313            "IFCRELAGGREGATES('{}',{},$,$,{},({}))",
314            guid2, owner_history, building, storey
315        );
316        self.add_entity(rel_str);
317
318        storey
319    }
320
321    // =========================================================================
322    // Light fixture entities
323    // =========================================================================
324
325    /// Add IfcLightFixtureType with optional shared geometry
326    ///
327    /// # Arguments
328    /// * `name` - Type name
329    /// * `manufacturer` - Manufacturer name
330    /// * `predefined_type` - Light fixture predefined type
331    /// * `owner_history` - Owner history reference
332    /// * `representation_map` - Optional shared geometry (IFCREPRESENTATIONMAP)
333    ///
334    /// When `representation_map` is provided, all fixture types share the same geometry,
335    /// avoiding duplication. This is the correct IFC pattern for variants.
336    pub fn add_light_fixture_type(
337        &mut self,
338        name: &str,
339        manufacturer: &str,
340        predefined_type: LightFixtureTypeEnum,
341        owner_history: EntityRef,
342    ) -> EntityRef {
343        self.add_light_fixture_type_with_geometry(
344            name,
345            manufacturer,
346            predefined_type,
347            owner_history,
348            None,
349        )
350    }
351
352    /// Add IfcLightFixtureType with shared representation map
353    pub fn add_light_fixture_type_with_geometry(
354        &mut self,
355        name: &str,
356        manufacturer: &str,
357        predefined_type: LightFixtureTypeEnum,
358        owner_history: EntityRef,
359        representation_map: Option<EntityRef>,
360    ) -> EntityRef {
361        let guid = self.generate_guid();
362        let escaped_name = Self::escape_string(name);
363
364        // RepresentationMaps is parameter 8 (index 7) in IFCLIGHTFIXTURETYPE
365        // IFCLIGHTFIXTURETYPE(GlobalId, OwnerHistory, Name, Description, ApplicableOccurrence,
366        //                     HasPropertySets, RepresentationMaps, Tag, ElementType, PredefinedType)
367        let rep_maps = representation_map
368            .map(|r| format!("({})", r))
369            .unwrap_or_else(|| "$".to_string());
370
371        let type_str = format!(
372            "IFCLIGHTFIXTURETYPE('{}',{},'{}','Luminaire from GLDF',$,$,{},$,$,{})",
373            guid,
374            owner_history,
375            escaped_name,
376            rep_maps,
377            predefined_type.to_step()
378        );
379        let fixture_type = self.add_entity(type_str);
380
381        // Add manufacturer property set
382        self.add_manufacturer_pset(fixture_type, owner_history, manufacturer, name);
383
384        fixture_type
385    }
386
387    /// Create simple box geometry for the light fixture (fallback when no L3D mesh)
388    fn create_fixture_geometry(&mut self, width: f64, depth: f64, height: f64) -> EntityRef {
389        let context_3d = self.entity_map.get("context_3d").copied();
390
391        // Create a simple extruded box - Rectangle profile
392        let profile_str = format!(
393            "IFCRECTANGLEPROFILEDEF(.AREA.,$,$,{:.3},{:.3})",
394            width, depth
395        );
396        let profile = self.add_entity(profile_str);
397
398        // Placement for extrusion (at origin, extruding upward)
399        let origin = self.add_entity("IFCCARTESIANPOINT((0.,0.,0.))".to_string());
400        let axis = self.add_entity("IFCDIRECTION((0.,0.,1.))".to_string());
401        let ref_dir = self.add_entity("IFCDIRECTION((1.,0.,0.))".to_string());
402        let extrusion_placement = self.add_axis2_placement_3d(origin, Some(axis), Some(ref_dir));
403
404        // Direction of extrusion
405        let extrude_dir = self.add_entity("IFCDIRECTION((0.,0.,1.))".to_string());
406
407        // Extruded solid
408        let solid_str = format!(
409            "IFCEXTRUDEDAREASOLID({},{},{},{:.3})",
410            profile, extrusion_placement, extrude_dir, height
411        );
412        let solid = self.add_entity(solid_str);
413
414        // Shape representation
415        let context_str = context_3d
416            .map(|c| c.to_string())
417            .unwrap_or_else(|| "$".to_string());
418        let shape_str = format!(
419            "IFCSHAPEREPRESENTATION({},'Body','SweptSolid',({}))",
420            context_str, solid
421        );
422        let shape_rep = self.add_entity(shape_str);
423
424        // Product definition shape
425        let prod_str = format!("IFCPRODUCTDEFINITIONSHAPE($,$,({}))", shape_rep);
426        self.add_entity(prod_str)
427    }
428
429    /// Create a representation map for shared geometry
430    ///
431    /// Creates geometry that can be referenced by multiple IFCLIGHTFIXTURETYPE entities.
432    /// This avoids duplicating geometry for each variant.
433    ///
434    /// # Arguments
435    /// * `mesh` - Optional triangulated mesh data. If None, creates default box.
436    ///
437    /// # Returns
438    /// EntityRef to IFCREPRESENTATIONMAP
439    pub fn create_representation_map(&mut self, mesh: Option<&MeshData>) -> EntityRef {
440        // Create origin for the map
441        let origin = self.add_entity("IFCCARTESIANPOINT((0.,0.,0.))".to_string());
442        let axis2_placement = self.add_axis2_placement_3d(origin, None, None);
443
444        // Create the shape representation
445        let shape_rep = if let Some(m) = mesh {
446            if !m.vertices.is_empty() && !m.triangles.is_empty() {
447                self.create_tessellated_shape_representation(m)
448            } else {
449                self.create_box_shape_representation(0.3, 0.3, 0.1)
450            }
451        } else {
452            self.create_box_shape_representation(0.3, 0.3, 0.1)
453        };
454
455        // Create the representation map
456        let map_str = format!("IFCREPRESENTATIONMAP({},{})", axis2_placement, shape_rep);
457        self.add_entity(map_str)
458    }
459
460    /// Create tessellated shape representation (without ProductDefinitionShape wrapper)
461    fn create_tessellated_shape_representation(&mut self, mesh: &MeshData) -> EntityRef {
462        let context_3d = self.entity_map.get("context_3d").copied();
463
464        // Build vertex list
465        let coords_str: String = mesh
466            .vertices
467            .iter()
468            .map(|(x, y, z)| format!("({:.6},{:.6},{:.6})", x, y, z))
469            .collect::<Vec<_>>()
470            .join(",");
471
472        let point_list_str = format!("IFCCARTESIANPOINTLIST3D(({}))", coords_str);
473        let point_list = self.add_entity(point_list_str);
474
475        // Build triangle index list
476        let triangles_str: String = mesh
477            .triangles
478            .iter()
479            .map(|(a, b, c)| format!("({},{},{})", a, b, c))
480            .collect::<Vec<_>>()
481            .join(",");
482
483        let faceset_str = format!(
484            "IFCTRIANGULATEDFACESET({},$,.T.,({}),())",
485            point_list, triangles_str
486        );
487        let faceset = self.add_entity(faceset_str);
488
489        let context_str = context_3d
490            .map(|c| c.to_string())
491            .unwrap_or_else(|| "$".to_string());
492        let shape_str = format!(
493            "IFCSHAPEREPRESENTATION({},'Body','Tessellation',({}))",
494            context_str, faceset
495        );
496        self.add_entity(shape_str)
497    }
498
499    /// Create box shape representation (without ProductDefinitionShape wrapper)
500    fn create_box_shape_representation(
501        &mut self,
502        width: f64,
503        height: f64,
504        depth: f64,
505    ) -> EntityRef {
506        let context_3d = self.entity_map.get("context_3d").copied();
507
508        let origin = self.add_entity("IFCCARTESIANPOINT((0.,0.,0.))".to_string());
509        let axis2 = self.add_axis2_placement_3d(origin, None, None);
510
511        let profile_str = format!(
512            "IFCRECTANGLEPROFILEDEF(.AREA.,'Luminaire Profile',{},{},{})",
513            axis2, width, height
514        );
515        let profile = self.add_entity(profile_str);
516
517        let dir_str = "IFCDIRECTION((0.,0.,1.))";
518        let dir = self.add_entity(dir_str.to_string());
519
520        let solid_str = format!(
521            "IFCEXTRUDEDAREASOLID({},{},{},{})",
522            profile, axis2, dir, depth
523        );
524        let solid = self.add_entity(solid_str);
525
526        let context_str = context_3d
527            .map(|c| c.to_string())
528            .unwrap_or_else(|| "$".to_string());
529        let shape_str = format!(
530            "IFCSHAPEREPRESENTATION({},'Body','SweptSolid',({}))",
531            context_str, solid
532        );
533        self.add_entity(shape_str)
534    }
535
536    /// Create tessellated geometry from L3D mesh data
537    ///
538    /// Converts triangulated mesh to IFC format:
539    /// - IFCCARTESIANPOINTLIST3D for vertex positions
540    /// - IFCTRIANGULATEDFACESET for triangle indices
541    /// - IFCSHAPEREPRESENTATION with 'Tessellation' type
542    ///
543    /// # Arguments
544    /// * `mesh` - Triangulated mesh data with vertices and triangle indices
545    ///
546    /// # Returns
547    /// EntityRef to IFCPRODUCTDEFINITIONSHAPE
548    pub fn create_tessellated_geometry(&mut self, mesh: &MeshData) -> EntityRef {
549        let context_3d = self.entity_map.get("context_3d").copied();
550
551        // Build vertex list: ((x1,y1,z1),(x2,y2,z2),...)
552        let coords_str: String = mesh
553            .vertices
554            .iter()
555            .map(|(x, y, z)| format!("({:.6},{:.6},{:.6})", x, y, z))
556            .collect::<Vec<_>>()
557            .join(",");
558
559        let point_list_str = format!("IFCCARTESIANPOINTLIST3D(({}))", coords_str);
560        let point_list = self.add_entity(point_list_str);
561
562        // Build triangle index list: ((1,2,3),(4,5,6),...)
563        // IFC uses 1-based indices
564        let triangles_str: String = mesh
565            .triangles
566            .iter()
567            .map(|(a, b, c)| format!("({},{},{})", a, b, c))
568            .collect::<Vec<_>>()
569            .join(",");
570
571        // IFCTRIANGULATEDFACESET(Coordinates, Normals, Closed, CoordIndex, PnIndex)
572        // We use:
573        // - Coordinates: the point list
574        // - Normals: $ (auto-calculate)
575        // - Closed: .T. (closed solid) or .F. (open surface)
576        // - CoordIndex: triangle indices
577        // - PnIndex: $ (not used when Normals is $)
578        let faceset_str = format!(
579            "IFCTRIANGULATEDFACESET({},$,.T.,({}),())",
580            point_list, triangles_str
581        );
582        let faceset = self.add_entity(faceset_str);
583
584        // Shape representation with 'Tessellation' type
585        let context_str = context_3d
586            .map(|c| c.to_string())
587            .unwrap_or_else(|| "$".to_string());
588        let shape_str = format!(
589            "IFCSHAPEREPRESENTATION({},'Body','Tessellation',({}))",
590            context_str, faceset
591        );
592        let shape_rep = self.add_entity(shape_str);
593
594        // Product definition shape
595        let prod_str = format!("IFCPRODUCTDEFINITIONSHAPE($,$,({}))", shape_rep);
596        self.add_entity(prod_str)
597    }
598
599    /// Add IfcLightFixture occurrence with geometry
600    ///
601    /// # Arguments
602    /// * `name` - Fixture name
603    /// * `owner_history` - Owner history reference
604    /// * `storey` - Building storey reference
605    /// * `fixture_type` - Optional fixture type reference
606    /// * `representation_map` - Optional shared representation map. If provided, uses IFCMAPPEDITEM.
607    ///   If None, creates inline geometry from mesh_data or default box.
608    /// * `mesh_data` - Optional L3D mesh data for inline tessellated geometry (used when no rep map).
609    pub fn add_light_fixture_with_map(
610        &mut self,
611        name: &str,
612        owner_history: EntityRef,
613        storey: EntityRef,
614        fixture_type: Option<EntityRef>,
615        representation_map: Option<EntityRef>,
616        mesh_data: Option<&MeshData>,
617    ) -> EntityRef {
618        // Create placement entities
619        let origin = self.add_entity("IFCCARTESIANPOINT((0.,0.,0.))".to_string());
620        let axis2_placement = self.add_axis2_placement_3d(origin, None, None);
621        let placement = self.add_local_placement(None, axis2_placement);
622
623        // Create geometry - use mapped item if rep map provided, otherwise inline geometry
624        let geometry = if let Some(rep_map) = representation_map {
625            self.create_mapped_item_geometry(rep_map)
626        } else if let Some(mesh) = mesh_data {
627            if !mesh.vertices.is_empty() && !mesh.triangles.is_empty() {
628                self.create_tessellated_geometry(mesh)
629            } else {
630                self.create_fixture_geometry(0.3, 0.3, 0.1)
631            }
632        } else {
633            self.create_fixture_geometry(0.3, 0.3, 0.1)
634        };
635
636        let guid = self.generate_guid();
637        let escaped_name = Self::escape_string(name);
638        let fixture_str = format!(
639            "IFCLIGHTFIXTURE('{}',{},'{}','',$,{},{},$,.NOTDEFINED.)",
640            guid, owner_history, escaped_name, placement, geometry
641        );
642        let fixture = self.add_entity(fixture_str);
643
644        // Assign to spatial container (storey)
645        let guid2 = self.generate_guid();
646        let rel_str = format!(
647            "IFCRELCONTAINEDINSPATIALSTRUCTURE('{}',{},$,$,({}),{})",
648            guid2, owner_history, fixture, storey
649        );
650        self.add_entity(rel_str);
651
652        // Assign type if provided
653        if let Some(ft) = fixture_type {
654            let guid3 = self.generate_guid();
655            let rel_type_str = format!(
656                "IFCRELDEFINESBYTYPE('{}',{},$,$,({}),{})",
657                guid3, owner_history, fixture, ft
658            );
659            self.add_entity(rel_type_str);
660        }
661
662        // Associate any accumulated light sources with this fixture
663        if !self.light_source_ids.is_empty() {
664            let guid4 = self.generate_guid();
665            let sources_list = self
666                .light_source_ids
667                .iter()
668                .map(|id| id.to_string())
669                .collect::<Vec<_>>()
670                .join(",");
671            let rel_group_str = format!(
672                "IFCRELASSIGNSTOGROUP('{}',{},'LightSources','Light sources for fixture',$,({}),{})",
673                guid4, owner_history, sources_list, fixture
674            );
675            self.add_entity(rel_group_str);
676        }
677
678        fixture
679    }
680
681    /// Create geometry using IFCMAPPEDITEM referencing a representation map
682    fn create_mapped_item_geometry(&mut self, rep_map: EntityRef) -> EntityRef {
683        let context_3d = self.entity_map.get("context_3d").copied();
684
685        // Create identity transform for the mapped item
686        // IFCCARTESIANTRANSFORMATIONOPERATOR3D(Axis1, Axis2, LocalOrigin, Scale, Axis3)
687        // LocalOrigin must be IFCCARTESIANPOINT, not IFCAXIS2PLACEMENT3D
688        let origin = self.add_entity("IFCCARTESIANPOINT((0.,0.,0.))".to_string());
689        let transform = self.add_entity(format!(
690            "IFCCARTESIANTRANSFORMATIONOPERATOR3D($,$,{},1.,$)",
691            origin
692        ));
693
694        // Create the mapped item
695        let mapped_item = self.add_entity(format!("IFCMAPPEDITEM({},{})", rep_map, transform));
696
697        // Shape representation using the mapped item
698        let context_str = context_3d
699            .map(|c| c.to_string())
700            .unwrap_or_else(|| "$".to_string());
701        let shape_str = format!(
702            "IFCSHAPEREPRESENTATION({},'Body','MappedRepresentation',({}))",
703            context_str, mapped_item
704        );
705        let shape_rep = self.add_entity(shape_str);
706
707        // Product definition shape
708        let prod_str = format!("IFCPRODUCTDEFINITIONSHAPE($,$,({}))", shape_rep);
709        self.add_entity(prod_str)
710    }
711
712    /// Add IfcLightFixture occurrence with geometry and connected light sources
713    ///
714    /// # Arguments
715    /// * `name` - Fixture name
716    /// * `owner_history` - Owner history reference
717    /// * `storey` - Building storey reference
718    /// * `fixture_type` - Optional fixture type reference
719    /// * `mesh_data` - Optional L3D mesh data for tessellated geometry.
720    ///   If None, creates a default 0.3m x 0.3m x 0.1m box.
721    pub fn add_light_fixture(
722        &mut self,
723        name: &str,
724        owner_history: EntityRef,
725        storey: EntityRef,
726        fixture_type: Option<EntityRef>,
727        mesh_data: Option<&MeshData>,
728    ) -> EntityRef {
729        // Create placement entities separately (Issue 1 fix)
730        let origin = self.add_entity("IFCCARTESIANPOINT((0.,0.,0.))".to_string());
731        let axis2_placement = self.add_axis2_placement_3d(origin, None, None);
732        let placement = self.add_local_placement(None, axis2_placement);
733
734        // Create geometry - use L3D mesh if available, otherwise fallback to box
735        let geometry = if let Some(mesh) = mesh_data {
736            if !mesh.vertices.is_empty() && !mesh.triangles.is_empty() {
737                self.create_tessellated_geometry(mesh)
738            } else {
739                // Empty mesh, use default box
740                self.create_fixture_geometry(0.3, 0.3, 0.1)
741            }
742        } else {
743            // No mesh data, use default 0.3m x 0.3m x 0.1m box
744            self.create_fixture_geometry(0.3, 0.3, 0.1)
745        };
746
747        let guid = self.generate_guid();
748        let escaped_name = Self::escape_string(name);
749        let fixture_str = format!(
750            "IFCLIGHTFIXTURE('{}',{},'{}','',$,{},{},$,.NOTDEFINED.)",
751            guid, owner_history, escaped_name, placement, geometry
752        );
753        let fixture = self.add_entity(fixture_str);
754
755        // Assign to spatial container (storey)
756        let guid2 = self.generate_guid();
757        let rel_str = format!(
758            "IFCRELCONTAINEDINSPATIALSTRUCTURE('{}',{},$,$,({}),{})",
759            guid2, owner_history, fixture, storey
760        );
761        self.add_entity(rel_str);
762
763        // Assign type if provided
764        if let Some(ft) = fixture_type {
765            let guid3 = self.generate_guid();
766            let type_str = format!(
767                "IFCRELDEFINESBYTYPE('{}',{},$,$,({}),{})",
768                guid3, owner_history, fixture, ft
769            );
770            self.add_entity(type_str);
771        }
772
773        // Connect light sources to fixture (Issue 2 fix)
774        if !self.light_source_ids.is_empty() {
775            let source_refs: Vec<String> = self
776                .light_source_ids
777                .iter()
778                .map(|s| s.to_string())
779                .collect();
780            let guid4 = self.generate_guid();
781            let group_str = format!(
782                "IFCRELASSIGNSTOGROUP('{}',{},'LightSources','Light sources for fixture',$,({}),{})",
783                guid4, owner_history, source_refs.join(","), fixture
784            );
785            self.add_entity(group_str);
786        }
787
788        fixture
789    }
790
791    /// Add manufacturer property set
792    fn add_manufacturer_pset(
793        &mut self,
794        element: EntityRef,
795        owner_history: EntityRef,
796        manufacturer: &str,
797        model_reference: &str,
798    ) {
799        let mfr_str = format!(
800            "IFCPROPERTYSINGLEVALUE('Manufacturer',$,IFCLABEL('{}'),$)",
801            Self::escape_string(manufacturer)
802        );
803        let mfr_value = self.add_entity(mfr_str);
804
805        let model_str = format!(
806            "IFCPROPERTYSINGLEVALUE('ModelReference',$,IFCLABEL('{}'),$)",
807            Self::escape_string(model_reference)
808        );
809        let model_value = self.add_entity(model_str);
810
811        let guid = self.generate_guid();
812        let pset_str = format!(
813            "IFCPROPERTYSET('{}',{},'Pset_ManufacturerTypeInformation',$,({},{}))",
814            guid, owner_history, mfr_value, model_value
815        );
816        let pset = self.add_entity(pset_str);
817
818        let guid2 = self.generate_guid();
819        let rel_str = format!(
820            "IFCRELDEFINESBYPROPERTIES('{}',{},$,$,({}),{})",
821            guid2, owner_history, element, pset
822        );
823        self.add_entity(rel_str);
824    }
825
826    // =========================================================================
827    // Photometric data entities
828    // =========================================================================
829
830    /// Add IfcExternalReference for LDT/IES photometric file
831    pub fn add_external_reference(
832        &mut self,
833        location: &str,
834        identification: Option<&str>,
835        name: &str,
836    ) -> EntityRef {
837        let ident = identification
838            .map(|s| format!("'{}'", Self::escape_string(s)))
839            .unwrap_or_else(|| "$".to_string());
840
841        let ref_str = format!(
842            "IFCEXTERNALREFERENCE('{}',{},'{}')",
843            Self::escape_string(location),
844            ident,
845            Self::escape_string(name)
846        );
847        self.add_entity(ref_str)
848    }
849
850    /// Add IfcLightSourceGoniometric
851    pub fn add_light_source_goniometric(
852        &mut self,
853        name: &str,
854        colour_appearance: Option<(f64, f64, f64)>,
855        colour_temperature: f64,
856        luminous_flux: f64,
857        emission_source: LightEmissionSourceEnum,
858        photometry_file: Option<&str>,
859    ) -> EntityRef {
860        // Position at origin - create entities separately (Issue 1 fix)
861        let origin = self.add_entity("IFCCARTESIANPOINT((0.,0.,0.))".to_string());
862        let axis = self.add_entity("IFCDIRECTION((0.,0.,-1.))".to_string()); // Light points down
863        let ref_dir = self.add_entity("IFCDIRECTION((1.,0.,0.))".to_string());
864        let position = self.add_axis2_placement_3d(origin, Some(axis), Some(ref_dir));
865
866        // Colour appearance (IfcColourRgb)
867        let colour_ref = if let Some((r, g, b)) = colour_appearance {
868            let colour_str = format!("IFCCOLOURRGB($,{:.4},{:.4},{:.4})", r, g, b);
869            let colour = self.add_entity(colour_str);
870            OptionalRef::Some(colour)
871        } else {
872            OptionalRef::None
873        };
874
875        // External photometric reference
876        let distribution_ref = if let Some(file_path) = photometry_file {
877            let escaped_name = Self::escape_string(name);
878            let ext_str = format!(
879                "IFCEXTERNALREFERENCE('{}',$,'{}')",
880                Self::escape_string(file_path),
881                escaped_name
882            );
883            let ext_ref = self.add_entity(ext_str);
884            OptionalRef::Some(ext_ref)
885        } else {
886            OptionalRef::None
887        };
888
889        // IfcLightSourceGoniometric
890        let escaped_name = Self::escape_string(name);
891        let ls_str = format!(
892            "IFCLIGHTSOURCEGONIOMETRIC('{}',{},0.,1.,{},{},{:.1},{:.1},{},{})",
893            escaped_name,
894            colour_ref,
895            position,
896            colour_ref,
897            colour_temperature,
898            luminous_flux,
899            emission_source.to_step(),
900            distribution_ref
901        );
902        let light_source = self.add_entity(ls_str);
903
904        // Track for later connection to fixture (Issue 2 fix)
905        self.light_source_ids.push(light_source);
906
907        light_source
908    }
909
910    /// Add IfcLightSourceGoniometric with embedded distribution data
911    ///
912    /// Instead of using an external file reference, this embeds the photometric
913    /// distribution directly in the IFC using IFCLIGHTINTENSITYDISTRIBUTION.
914    pub fn add_light_source_goniometric_with_distribution(
915        &mut self,
916        name: &str,
917        colour_appearance: Option<(f64, f64, f64)>,
918        colour_temperature: f64,
919        luminous_flux: f64,
920        emission_source: LightEmissionSourceEnum,
921        distribution_data: &[(f64, f64, f64)],
922    ) -> EntityRef {
923        // Position at origin
924        let origin = self.add_entity("IFCCARTESIANPOINT((0.,0.,0.))".to_string());
925        let axis = self.add_entity("IFCDIRECTION((0.,0.,-1.))".to_string());
926        let ref_dir = self.add_entity("IFCDIRECTION((1.,0.,0.))".to_string());
927        let position = self.add_axis2_placement_3d(origin, Some(axis), Some(ref_dir));
928
929        // Colour appearance
930        let colour_ref = if let Some((r, g, b)) = colour_appearance {
931            let colour_str = format!("IFCCOLOURRGB($,{:.4},{:.4},{:.4})", r, g, b);
932            let colour = self.add_entity(colour_str);
933            OptionalRef::Some(colour)
934        } else {
935            OptionalRef::None
936        };
937
938        // Create embedded light intensity distribution
939        let distribution_ref = if !distribution_data.is_empty() {
940            let dist = self.add_light_intensity_distribution("TYPE_C", distribution_data);
941            OptionalRef::Some(dist)
942        } else {
943            OptionalRef::None
944        };
945
946        // IfcLightSourceGoniometric
947        let escaped_name = Self::escape_string(name);
948        let ls_str = format!(
949            "IFCLIGHTSOURCEGONIOMETRIC('{}',{},0.,1.,{},{},{:.1},{:.1},{},{})",
950            escaped_name,
951            colour_ref,
952            position,
953            colour_ref,
954            colour_temperature,
955            luminous_flux,
956            emission_source.to_step(),
957            distribution_ref
958        );
959        let light_source = self.add_entity(ls_str);
960
961        // Track for later connection to fixture
962        self.light_source_ids.push(light_source);
963
964        light_source
965    }
966
967    /// Add IfcLightSourceGoniometric with a pre-existing distribution reference
968    ///
969    /// This allows sharing the same IFCLIGHTINTENSITYDISTRIBUTION across multiple light sources.
970    pub fn add_light_source_goniometric_with_distribution_ref(
971        &mut self,
972        name: &str,
973        colour_appearance: Option<(f64, f64, f64)>,
974        colour_temperature: f64,
975        luminous_flux: f64,
976        emission_source: LightEmissionSourceEnum,
977        distribution_ref: EntityRef,
978    ) -> EntityRef {
979        // Position at origin
980        let origin = self.add_entity("IFCCARTESIANPOINT((0.,0.,0.))".to_string());
981        let axis = self.add_entity("IFCDIRECTION((0.,0.,-1.))".to_string());
982        let ref_dir = self.add_entity("IFCDIRECTION((1.,0.,0.))".to_string());
983        let position = self.add_axis2_placement_3d(origin, Some(axis), Some(ref_dir));
984
985        // Colour appearance
986        let colour_ref = if let Some((r, g, b)) = colour_appearance {
987            let colour_str = format!("IFCCOLOURRGB($,{:.4},{:.4},{:.4})", r, g, b);
988            let colour = self.add_entity(colour_str);
989            OptionalRef::Some(colour)
990        } else {
991            OptionalRef::None
992        };
993
994        // IfcLightSourceGoniometric with shared distribution reference
995        let escaped_name = Self::escape_string(name);
996        let ls_str = format!(
997            "IFCLIGHTSOURCEGONIOMETRIC('{}',{},0.,1.,{},{},{:.1},{:.1},{},{})",
998            escaped_name,
999            colour_ref,
1000            position,
1001            colour_ref,
1002            colour_temperature,
1003            luminous_flux,
1004            emission_source.to_step(),
1005            distribution_ref
1006        );
1007        let light_source = self.add_entity(ls_str);
1008
1009        // Track for later connection to fixture
1010        self.light_source_ids.push(light_source);
1011
1012        light_source
1013    }
1014
1015    /// Add IfcLightIntensityDistribution for embedded photometric data
1016    ///
1017    /// Distribution data format: Vec<(main_angle, secondary_angle, intensity)>
1018    /// This is grouped by main angle to create IFCLIGHTDISTRIBUTIONDATA entries
1019    pub fn add_light_intensity_distribution(
1020        &mut self,
1021        distribution_type: &str,
1022        distribution_data: &[(f64, f64, f64)],
1023    ) -> EntityRef {
1024        // Group data by main angle (C-plane)
1025        // IFC format: IFCLIGHTDISTRIBUTIONDATA(MainPlaneAngle, (SecondaryAngles...), (Intensities...))
1026        let mut grouped: std::collections::BTreeMap<i64, Vec<(f64, f64)>> =
1027            std::collections::BTreeMap::new();
1028        for &(main, secondary, intensity) in distribution_data {
1029            // Use integer key to group by main angle (avoid float comparison issues)
1030            let key = (main * 100.0) as i64;
1031            grouped.entry(key).or_default().push((secondary, intensity));
1032        }
1033
1034        // Create distribution data entries for each C-plane
1035        let mut data_entries = Vec::new();
1036        for (main_key, values) in grouped {
1037            let main_angle = main_key as f64 / 100.0;
1038
1039            // Sort by secondary angle (gamma)
1040            let mut sorted_values = values;
1041            sorted_values
1042                .sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
1043
1044            // Build lists for secondary angles and intensities
1045            let secondary_list: Vec<String> = sorted_values
1046                .iter()
1047                .map(|(s, _)| format!("{:.2}", s))
1048                .collect();
1049            let intensity_list: Vec<String> = sorted_values
1050                .iter()
1051                .map(|(_, i)| format!("{:.4}", i))
1052                .collect();
1053
1054            let entry_str = format!(
1055                "IFCLIGHTDISTRIBUTIONDATA({:.2},({}),({}))",
1056                main_angle,
1057                secondary_list.join(","),
1058                intensity_list.join(",")
1059            );
1060            let entry = self.add_entity(entry_str);
1061            data_entries.push(entry.to_string());
1062        }
1063
1064        let data_list = if data_entries.is_empty() {
1065            "$".to_string()
1066        } else {
1067            format!("({})", data_entries.join(","))
1068        };
1069
1070        let dist_str = format!(
1071            "IFCLIGHTINTENSITYDISTRIBUTION(.{}.,{})",
1072            distribution_type, data_list
1073        );
1074        self.add_entity(dist_str)
1075    }
1076
1077    // =========================================================================
1078    // Property sets for light fixtures
1079    // =========================================================================
1080
1081    /// Add Pset_LightFixtureTypeCommon property set
1082    pub fn add_light_fixture_common_pset(
1083        &mut self,
1084        element: EntityRef,
1085        owner_history: EntityRef,
1086        number_of_sources: Option<i32>,
1087        total_wattage: Option<f64>,
1088        light_fixture_mounting_type: Option<&str>,
1089        light_fixture_placement_type: Option<&str>,
1090    ) -> EntityRef {
1091        let mut properties = Vec::new();
1092
1093        if let Some(n) = number_of_sources {
1094            let prop_str = format!(
1095                "IFCPROPERTYSINGLEVALUE('NumberOfSources',$,IFCINTEGER({}),$)",
1096                n
1097            );
1098            let prop = self.add_entity(prop_str);
1099            properties.push(prop);
1100        }
1101
1102        if let Some(w) = total_wattage {
1103            let prop_str = format!(
1104                "IFCPROPERTYSINGLEVALUE('TotalWattage',$,IFCPOWERMEASURE({:.2}),$)",
1105                w
1106            );
1107            let prop = self.add_entity(prop_str);
1108            properties.push(prop);
1109        }
1110
1111        if let Some(mt) = light_fixture_mounting_type {
1112            let prop_str = format!(
1113                "IFCPROPERTYSINGLEVALUE('LightFixtureMountingType',$,IFCLABEL('{}'),$)",
1114                Self::escape_string(mt)
1115            );
1116            let prop = self.add_entity(prop_str);
1117            properties.push(prop);
1118        }
1119
1120        if let Some(pt) = light_fixture_placement_type {
1121            let prop_str = format!(
1122                "IFCPROPERTYSINGLEVALUE('LightFixturePlacementType',$,IFCLABEL('{}'),$)",
1123                Self::escape_string(pt)
1124            );
1125            let prop = self.add_entity(prop_str);
1126            properties.push(prop);
1127        }
1128
1129        if properties.is_empty() {
1130            return EntityRef::new(0);
1131        }
1132
1133        let prop_list: Vec<String> = properties.iter().map(|p| p.to_string()).collect();
1134
1135        let guid = self.generate_guid();
1136        let pset_str = format!(
1137            "IFCPROPERTYSET('{}',{},'Pset_LightFixtureTypeCommon',$,({}))",
1138            guid,
1139            owner_history,
1140            prop_list.join(",")
1141        );
1142        let pset = self.add_entity(pset_str);
1143
1144        let guid2 = self.generate_guid();
1145        let rel_str = format!(
1146            "IFCRELDEFINESBYPROPERTIES('{}',{},$,$,({}),{})",
1147            guid2, owner_history, element, pset
1148        );
1149        self.add_entity(rel_str);
1150
1151        pset
1152    }
1153
1154    /// Add electrical properties property set
1155    pub fn add_electrical_pset(
1156        &mut self,
1157        element: EntityRef,
1158        owner_history: EntityRef,
1159        rated_voltage: Option<f64>,
1160        rated_current: Option<f64>,
1161        power_factor: Option<f64>,
1162        ip_code: Option<&str>,
1163    ) -> EntityRef {
1164        let mut properties = Vec::new();
1165
1166        if let Some(v) = rated_voltage {
1167            let prop_str = format!(
1168                "IFCPROPERTYSINGLEVALUE('RatedVoltage',$,IFCELECTRICVOLTAGEMEASURE({:.1}),$)",
1169                v
1170            );
1171            let prop = self.add_entity(prop_str);
1172            properties.push(prop);
1173        }
1174
1175        if let Some(i) = rated_current {
1176            let prop_str = format!(
1177                "IFCPROPERTYSINGLEVALUE('RatedCurrent',$,IFCELECTRICCURRENTMEASURE({:.3}),$)",
1178                i
1179            );
1180            let prop = self.add_entity(prop_str);
1181            properties.push(prop);
1182        }
1183
1184        if let Some(pf) = power_factor {
1185            let prop_str = format!(
1186                "IFCPROPERTYSINGLEVALUE('PowerFactor',$,IFCNORMALISEDRATIOMEASURE({:.3}),$)",
1187                pf
1188            );
1189            let prop = self.add_entity(prop_str);
1190            properties.push(prop);
1191        }
1192
1193        if let Some(ip) = ip_code {
1194            // Issue 5 fix: Don't double-prefix with IP
1195            let normalized_ip = ip.trim_start_matches("IP").trim_start_matches("ip");
1196            let prop_str = format!(
1197                "IFCPROPERTYSINGLEVALUE('IPCode',$,IFCLABEL('IP{}'),$)",
1198                Self::escape_string(normalized_ip)
1199            );
1200            let prop = self.add_entity(prop_str);
1201            properties.push(prop);
1202        }
1203
1204        if properties.is_empty() {
1205            return EntityRef::new(0);
1206        }
1207
1208        let prop_list: Vec<String> = properties.iter().map(|p| p.to_string()).collect();
1209
1210        let guid = self.generate_guid();
1211        let pset_str = format!(
1212            "IFCPROPERTYSET('{}',{},'Pset_ElectricalDeviceCommon',$,({}))",
1213            guid,
1214            owner_history,
1215            prop_list.join(",")
1216        );
1217        let pset = self.add_entity(pset_str);
1218
1219        let guid2 = self.generate_guid();
1220        let rel_str = format!(
1221            "IFCRELDEFINESBYPROPERTIES('{}',{},$,$,({}),{})",
1222            guid2, owner_history, element, pset
1223        );
1224        self.add_entity(rel_str);
1225
1226        pset
1227    }
1228
1229    /// Add comprehensive electrical device properties (Pset_ElectricalDeviceCommon)
1230    ///
1231    /// Full support for Electrical Information Exchange MVD (SPARKIE).
1232    /// See: <https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/Pset_ElectricalDeviceCommon.htm>
1233    pub fn add_electrical_device_pset(
1234        &mut self,
1235        element: EntityRef,
1236        owner_history: EntityRef,
1237        props: &ElectricalDeviceProperties,
1238    ) -> EntityRef {
1239        let mut properties = Vec::new();
1240
1241        // RatedCurrent (IfcElectricCurrentMeasure)
1242        if let Some(current) = props.rated_current {
1243            let prop = self.add_entity(format!(
1244                "IFCPROPERTYSINGLEVALUE('RatedCurrent',$,IFCELECTRICCURRENTMEASURE({:.3}),$)",
1245                current
1246            ));
1247            properties.push(prop);
1248        }
1249
1250        // RatedVoltage (IfcElectricVoltageMeasure) - can be bounded
1251        if let Some(voltage) = props.rated_voltage {
1252            if let Some(voltage_max) = props.rated_voltage_max {
1253                // Bounded value with min/max
1254                let prop = self.add_entity(format!(
1255                    "IFCPROPERTYBOUNDEDVALUE('RatedVoltage',$,IFCELECTRICVOLTAGEMEASURE({:.1}),IFCELECTRICVOLTAGEMEASURE({:.1}),$,$)",
1256                    voltage, voltage_max
1257                ));
1258                properties.push(prop);
1259            } else {
1260                let prop = self.add_entity(format!(
1261                    "IFCPROPERTYSINGLEVALUE('RatedVoltage',$,IFCELECTRICVOLTAGEMEASURE({:.1}),$)",
1262                    voltage
1263                ));
1264                properties.push(prop);
1265            }
1266        }
1267
1268        // NominalFrequencyRange (IfcFrequencyMeasure) - bounded
1269        if let (Some(freq_min), Some(freq_max)) =
1270            (props.nominal_frequency_min, props.nominal_frequency_max)
1271        {
1272            let prop = self.add_entity(format!(
1273                "IFCPROPERTYBOUNDEDVALUE('NominalFrequencyRange',$,IFCFREQUENCYMEASURE({:.1}),IFCFREQUENCYMEASURE({:.1}),$,$)",
1274                freq_min, freq_max
1275            ));
1276            properties.push(prop);
1277        }
1278
1279        // PowerFactor (IfcNormalisedRatioMeasure) - 0.0 to 1.0
1280        if let Some(pf) = props.power_factor {
1281            let prop = self.add_entity(format!(
1282                "IFCPROPERTYSINGLEVALUE('PowerFactor',$,IFCNORMALISEDRATIOMEASURE({:.3}),$)",
1283                pf.clamp(0.0, 1.0)
1284            ));
1285            properties.push(prop);
1286        }
1287
1288        // ConductorFunction (PEnum_ConductorFunctionEnum)
1289        if let Some(ref cf) = props.conductor_function {
1290            let prop = self.add_entity(format!(
1291                "IFCPROPERTYENUMERATEDVALUE('ConductorFunction',$,(IFCLABEL('{}')),{})",
1292                cf.to_step().trim_matches('.'),
1293                "$" // No enum reference needed for single value
1294            ));
1295            properties.push(prop);
1296        }
1297
1298        // NumberOfPoles (IfcCountMeasure)
1299        if let Some(poles) = props.number_of_poles {
1300            let prop = self.add_entity(format!(
1301                "IFCPROPERTYSINGLEVALUE('NumberOfPoles',$,IFCCOUNTMEASURE({}),$)",
1302                poles
1303            ));
1304            properties.push(prop);
1305        }
1306
1307        // HasProtectiveEarth (IfcBoolean)
1308        if let Some(has_pe) = props.has_protective_earth {
1309            let prop = self.add_entity(format!(
1310                "IFCPROPERTYSINGLEVALUE('HasProtectiveEarth',$,IFCBOOLEAN({}),$)",
1311                if has_pe { ".T." } else { ".F." }
1312            ));
1313            properties.push(prop);
1314        }
1315
1316        // InsulationStandardClass (PEnum_InsulationStandardClass)
1317        if let Some(ref isc) = props.insulation_standard_class {
1318            if *isc != InsulationStandardClass::NotDefined {
1319                let prop = self.add_entity(format!(
1320                    "IFCPROPERTYENUMERATEDVALUE('InsulationStandardClass',$,(IFCLABEL('{}')),{})",
1321                    isc.to_step().trim_matches('.'),
1322                    "$"
1323                ));
1324                properties.push(prop);
1325            }
1326        }
1327
1328        // IP_Code (IfcLabel)
1329        if let Some(ref ip) = props.ip_code {
1330            // Normalize IP code - ensure it starts with "IP"
1331            let normalized_ip = if ip.to_uppercase().starts_with("IP") {
1332                ip.clone()
1333            } else {
1334                format!("IP{}", ip)
1335            };
1336            let prop = self.add_entity(format!(
1337                "IFCPROPERTYSINGLEVALUE('IP_Code',$,IFCLABEL('{}'),$)",
1338                Self::escape_string(&normalized_ip)
1339            ));
1340            properties.push(prop);
1341        }
1342
1343        // IK_Code (IfcLabel)
1344        if let Some(ref ik) = props.ik_code {
1345            // Normalize IK code - ensure it starts with "IK"
1346            let normalized_ik = if ik.to_uppercase().starts_with("IK") {
1347                ik.clone()
1348            } else {
1349                format!("IK{}", ik)
1350            };
1351            let prop = self.add_entity(format!(
1352                "IFCPROPERTYSINGLEVALUE('IK_Code',$,IFCLABEL('{}'),$)",
1353                Self::escape_string(&normalized_ik)
1354            ));
1355            properties.push(prop);
1356        }
1357
1358        // EarthingStyle (IfcLabel)
1359        if let Some(ref es) = props.earthing_style {
1360            let prop = self.add_entity(format!(
1361                "IFCPROPERTYSINGLEVALUE('EarthingStyle',$,IFCLABEL('{}'),$)",
1362                Self::escape_string(es)
1363            ));
1364            properties.push(prop);
1365        }
1366
1367        // HeatDissipation (IfcPowerMeasure)
1368        if let Some(heat) = props.heat_dissipation {
1369            let prop = self.add_entity(format!(
1370                "IFCPROPERTYSINGLEVALUE('HeatDissipation',$,IFCPOWERMEASURE({:.2}),$)",
1371                heat
1372            ));
1373            properties.push(prop);
1374        }
1375
1376        // Power (IfcPowerMeasure)
1377        if let Some(power) = props.power {
1378            let prop = self.add_entity(format!(
1379                "IFCPROPERTYSINGLEVALUE('Power',$,IFCPOWERMEASURE({:.2}),$)",
1380                power
1381            ));
1382            properties.push(prop);
1383        }
1384
1385        // NominalPowerConsumption (IfcPowerMeasure)
1386        if let Some(npc) = props.nominal_power_consumption {
1387            let prop = self.add_entity(format!(
1388                "IFCPROPERTYSINGLEVALUE('NominalPowerConsumption',$,IFCPOWERMEASURE({:.2}),$)",
1389                npc
1390            ));
1391            properties.push(prop);
1392        }
1393
1394        // NumberOfPowerSupplyPorts (IfcInteger)
1395        if let Some(ports) = props.number_of_power_supply_ports {
1396            let prop = self.add_entity(format!(
1397                "IFCPROPERTYSINGLEVALUE('NumberOfPowerSupplyPorts',$,IFCINTEGER({}),$)",
1398                ports
1399            ));
1400            properties.push(prop);
1401        }
1402
1403        if properties.is_empty() {
1404            return EntityRef::new(0);
1405        }
1406
1407        let prop_list: Vec<String> = properties.iter().map(|p| p.to_string()).collect();
1408
1409        let guid = self.generate_guid();
1410        let pset = self.add_entity(format!(
1411            "IFCPROPERTYSET('{}',{},'Pset_ElectricalDeviceCommon',$,({}))",
1412            guid,
1413            owner_history,
1414            prop_list.join(",")
1415        ));
1416
1417        let guid2 = self.generate_guid();
1418        self.add_entity(format!(
1419            "IFCRELDEFINESBYPROPERTIES('{}',{},$,$,({}),{})",
1420            guid2, owner_history, element, pset
1421        ));
1422
1423        pset
1424    }
1425
1426    /// Add light fixture common properties (Pset_LightFixtureTypeCommon)
1427    ///
1428    /// Full support for Electrical Information Exchange MVD.
1429    pub fn add_light_fixture_common_pset_full(
1430        &mut self,
1431        element: EntityRef,
1432        owner_history: EntityRef,
1433        props: &LightFixtureCommonProperties,
1434    ) -> EntityRef {
1435        let mut properties = Vec::new();
1436
1437        // Reference (IfcIdentifier)
1438        if let Some(ref reference) = props.reference {
1439            let prop = self.add_entity(format!(
1440                "IFCPROPERTYSINGLEVALUE('Reference',$,IFCIDENTIFIER('{}'),$)",
1441                Self::escape_string(reference)
1442            ));
1443            properties.push(prop);
1444        }
1445
1446        // Status (PEnum_ElementStatus)
1447        if let Some(ref status) = props.status {
1448            let prop = self.add_entity(format!(
1449                "IFCPROPERTYENUMERATEDVALUE('Status',$,(IFCLABEL('{}')),$)",
1450                Self::escape_string(status)
1451            ));
1452            properties.push(prop);
1453        }
1454
1455        // NumberOfSources (IfcInteger)
1456        if let Some(num) = props.number_of_sources {
1457            let prop = self.add_entity(format!(
1458                "IFCPROPERTYSINGLEVALUE('NumberOfSources',$,IFCINTEGER({}),$)",
1459                num
1460            ));
1461            properties.push(prop);
1462        }
1463
1464        // TotalWattage (IfcPowerMeasure)
1465        if let Some(wattage) = props.total_wattage {
1466            let prop = self.add_entity(format!(
1467                "IFCPROPERTYSINGLEVALUE('TotalWattage',$,IFCPOWERMEASURE({:.2}),$)",
1468                wattage
1469            ));
1470            properties.push(prop);
1471        }
1472
1473        // LightFixtureMountingType (PEnum_LightFixtureMountingType)
1474        if let Some(ref mounting) = props.mounting_type {
1475            let prop = self.add_entity(format!(
1476                "IFCPROPERTYENUMERATEDVALUE('LightFixtureMountingType',$,(IFCLABEL('{}')),$)",
1477                Self::escape_string(mounting)
1478            ));
1479            properties.push(prop);
1480        }
1481
1482        // LightFixturePlacingType (PEnum_LightFixturePlacingType)
1483        if let Some(ref placing) = props.placing_type {
1484            let prop = self.add_entity(format!(
1485                "IFCPROPERTYENUMERATEDVALUE('LightFixturePlacingType',$,(IFCLABEL('{}')),$)",
1486                Self::escape_string(placing)
1487            ));
1488            properties.push(prop);
1489        }
1490
1491        // MaintenanceFactor (IfcReal)
1492        if let Some(mf) = props.maintenance_factor {
1493            let prop = self.add_entity(format!(
1494                "IFCPROPERTYSINGLEVALUE('MaintenanceFactor',$,IFCREAL({:.3}),$)",
1495                mf
1496            ));
1497            properties.push(prop);
1498        }
1499
1500        // MaximumPlenumSensibleLoad (IfcPowerMeasure)
1501        if let Some(load) = props.max_plenum_sensible_load {
1502            let prop = self.add_entity(format!(
1503                "IFCPROPERTYSINGLEVALUE('MaximumPlenumSensibleLoad',$,IFCPOWERMEASURE({:.2}),$)",
1504                load
1505            ));
1506            properties.push(prop);
1507        }
1508
1509        // MaximumSpaceSensibleLoad (IfcPowerMeasure)
1510        if let Some(load) = props.max_space_sensible_load {
1511            let prop = self.add_entity(format!(
1512                "IFCPROPERTYSINGLEVALUE('MaximumSpaceSensibleLoad',$,IFCPOWERMEASURE({:.2}),$)",
1513                load
1514            ));
1515            properties.push(prop);
1516        }
1517
1518        // SensibleLoadToRadiant (IfcPositiveRatioMeasure)
1519        if let Some(ratio) = props.sensible_load_to_radiant {
1520            let prop = self.add_entity(format!(
1521                "IFCPROPERTYSINGLEVALUE('SensibleLoadToRadiant',$,IFCPOSITIVERATIOMEASURE({:.3}),$)",
1522                ratio
1523            ));
1524            properties.push(prop);
1525        }
1526
1527        if properties.is_empty() {
1528            return EntityRef::new(0);
1529        }
1530
1531        let prop_list: Vec<String> = properties.iter().map(|p| p.to_string()).collect();
1532
1533        let guid = self.generate_guid();
1534        let pset = self.add_entity(format!(
1535            "IFCPROPERTYSET('{}',{},'Pset_LightFixtureTypeCommon',$,({}))",
1536            guid,
1537            owner_history,
1538            prop_list.join(",")
1539        ));
1540
1541        let guid2 = self.generate_guid();
1542        self.add_entity(format!(
1543            "IFCRELDEFINESBYPROPERTIES('{}',{},$,$,({}),{})",
1544            guid2, owner_history, element, pset
1545        ));
1546
1547        pset
1548    }
1549
1550    /// Add manufacturer type information (Pset_ManufacturerTypeInformation) - COBie
1551    pub fn add_manufacturer_info_pset(
1552        &mut self,
1553        element: EntityRef,
1554        owner_history: EntityRef,
1555        info: &ManufacturerTypeInfo,
1556    ) -> EntityRef {
1557        let mut properties = Vec::new();
1558
1559        if let Some(ref gtin) = info.global_trade_item_number {
1560            let prop = self.add_entity(format!(
1561                "IFCPROPERTYSINGLEVALUE('GlobalTradeItemNumber',$,IFCIDENTIFIER('{}'),$)",
1562                Self::escape_string(gtin)
1563            ));
1564            properties.push(prop);
1565        }
1566
1567        if let Some(ref article) = info.article_number {
1568            let prop = self.add_entity(format!(
1569                "IFCPROPERTYSINGLEVALUE('ArticleNumber',$,IFCIDENTIFIER('{}'),$)",
1570                Self::escape_string(article)
1571            ));
1572            properties.push(prop);
1573        }
1574
1575        if let Some(ref model_ref) = info.model_reference {
1576            let prop = self.add_entity(format!(
1577                "IFCPROPERTYSINGLEVALUE('ModelReference',$,IFCLABEL('{}'),$)",
1578                Self::escape_string(model_ref)
1579            ));
1580            properties.push(prop);
1581        }
1582
1583        if let Some(ref model_label) = info.model_label {
1584            let prop = self.add_entity(format!(
1585                "IFCPROPERTYSINGLEVALUE('ModelLabel',$,IFCLABEL('{}'),$)",
1586                Self::escape_string(model_label)
1587            ));
1588            properties.push(prop);
1589        }
1590
1591        if let Some(ref mfr) = info.manufacturer {
1592            let prop = self.add_entity(format!(
1593                "IFCPROPERTYSINGLEVALUE('Manufacturer',$,IFCLABEL('{}'),$)",
1594                Self::escape_string(mfr)
1595            ));
1596            properties.push(prop);
1597        }
1598
1599        if let Some(year) = info.production_year {
1600            let prop = self.add_entity(format!(
1601                "IFCPROPERTYSINGLEVALUE('ProductionYear',$,IFCINTEGER({}),$)",
1602                year
1603            ));
1604            properties.push(prop);
1605        }
1606
1607        if let Some(ref place) = info.assembly_place {
1608            let prop = self.add_entity(format!(
1609                "IFCPROPERTYSINGLEVALUE('AssemblyPlace',$,IFCLABEL('{}'),$)",
1610                Self::escape_string(place)
1611            ));
1612            properties.push(prop);
1613        }
1614
1615        if properties.is_empty() {
1616            return EntityRef::new(0);
1617        }
1618
1619        let prop_list: Vec<String> = properties.iter().map(|p| p.to_string()).collect();
1620
1621        let guid = self.generate_guid();
1622        let pset = self.add_entity(format!(
1623            "IFCPROPERTYSET('{}',{},'Pset_ManufacturerTypeInformation',$,({}))",
1624            guid,
1625            owner_history,
1626            prop_list.join(",")
1627        ));
1628
1629        let guid2 = self.generate_guid();
1630        self.add_entity(format!(
1631            "IFCRELDEFINESBYPROPERTIES('{}',{},$,$,({}),{})",
1632            guid2, owner_history, element, pset
1633        ));
1634
1635        pset
1636    }
1637
1638    /// Add warranty information (Pset_Warranty) - COBie
1639    pub fn add_warranty_pset(
1640        &mut self,
1641        element: EntityRef,
1642        owner_history: EntityRef,
1643        warranty: &WarrantyInfo,
1644    ) -> EntityRef {
1645        let mut properties = Vec::new();
1646
1647        if let Some(ref id) = warranty.warranty_identifier {
1648            let prop = self.add_entity(format!(
1649                "IFCPROPERTYSINGLEVALUE('WarrantyIdentifier',$,IFCIDENTIFIER('{}'),$)",
1650                Self::escape_string(id)
1651            ));
1652            properties.push(prop);
1653        }
1654
1655        if let Some(ref start) = warranty.warranty_start_date {
1656            let prop = self.add_entity(format!(
1657                "IFCPROPERTYSINGLEVALUE('WarrantyStartDate',$,IFCDATE('{}'),$)",
1658                Self::escape_string(start)
1659            ));
1660            properties.push(prop);
1661        }
1662
1663        if let Some(ref end) = warranty.warranty_end_date {
1664            let prop = self.add_entity(format!(
1665                "IFCPROPERTYSINGLEVALUE('WarrantyEndDate',$,IFCDATE('{}'),$)",
1666                Self::escape_string(end)
1667            ));
1668            properties.push(prop);
1669        }
1670
1671        if let Some(period) = warranty.warranty_period {
1672            let prop = self.add_entity(format!(
1673                "IFCPROPERTYSINGLEVALUE('WarrantyPeriod',$,IFCREAL({:.1}),$)",
1674                period
1675            ));
1676            properties.push(prop);
1677        }
1678
1679        if let Some(ref contact) = warranty.point_of_contact {
1680            let prop = self.add_entity(format!(
1681                "IFCPROPERTYSINGLEVALUE('PointOfContact',$,IFCLABEL('{}'),$)",
1682                Self::escape_string(contact)
1683            ));
1684            properties.push(prop);
1685        }
1686
1687        if let Some(ref terms) = warranty.terms_and_conditions {
1688            let prop = self.add_entity(format!(
1689                "IFCPROPERTYSINGLEVALUE('TermsAndConditions',$,IFCTEXT('{}'),$)",
1690                Self::escape_string(terms)
1691            ));
1692            properties.push(prop);
1693        }
1694
1695        if let Some(ref exclusions) = warranty.exclusions {
1696            let prop = self.add_entity(format!(
1697                "IFCPROPERTYSINGLEVALUE('Exclusions',$,IFCTEXT('{}'),$)",
1698                Self::escape_string(exclusions)
1699            ));
1700            properties.push(prop);
1701        }
1702
1703        if properties.is_empty() {
1704            return EntityRef::new(0);
1705        }
1706
1707        let prop_list: Vec<String> = properties.iter().map(|p| p.to_string()).collect();
1708
1709        let guid = self.generate_guid();
1710        let pset = self.add_entity(format!(
1711            "IFCPROPERTYSET('{}',{},'Pset_Warranty',$,({}))",
1712            guid,
1713            owner_history,
1714            prop_list.join(",")
1715        ));
1716
1717        let guid2 = self.generate_guid();
1718        self.add_entity(format!(
1719            "IFCRELDEFINESBYPROPERTIES('{}',{},$,$,({}),{})",
1720            guid2, owner_history, element, pset
1721        ));
1722
1723        pset
1724    }
1725
1726    /// Add service life information (Pset_ServiceLife) - COBie
1727    pub fn add_service_life_pset(
1728        &mut self,
1729        element: EntityRef,
1730        owner_history: EntityRef,
1731        service_life: &ServiceLifeInfo,
1732    ) -> EntityRef {
1733        let mut properties = Vec::new();
1734
1735        if let Some(duration) = service_life.service_life_duration {
1736            let prop = self.add_entity(format!(
1737                "IFCPROPERTYSINGLEVALUE('ServiceLifeDuration',$,IFCREAL({:.1}),$)",
1738                duration
1739            ));
1740            properties.push(prop);
1741        }
1742
1743        if let Some(ref sl_type) = service_life.service_life_type {
1744            let prop = self.add_entity(format!(
1745                "IFCPROPERTYENUMERATEDVALUE('ServiceLifeType',$,(IFCLABEL('{}')),$)",
1746                Self::escape_string(sl_type)
1747            ));
1748            properties.push(prop);
1749        }
1750
1751        if let Some(mtbf) = service_life.mean_time_between_failure {
1752            let prop = self.add_entity(format!(
1753                "IFCPROPERTYSINGLEVALUE('MeanTimeBetweenFailure',$,IFCREAL({:.0}),$)",
1754                mtbf
1755            ));
1756            properties.push(prop);
1757        }
1758
1759        if properties.is_empty() {
1760            return EntityRef::new(0);
1761        }
1762
1763        let prop_list: Vec<String> = properties.iter().map(|p| p.to_string()).collect();
1764
1765        let guid = self.generate_guid();
1766        let pset = self.add_entity(format!(
1767            "IFCPROPERTYSET('{}',{},'Pset_ServiceLife',$,({}))",
1768            guid,
1769            owner_history,
1770            prop_list.join(",")
1771        ));
1772
1773        let guid2 = self.generate_guid();
1774        self.add_entity(format!(
1775            "IFCRELDEFINESBYPROPERTIES('{}',{},$,$,({}),{})",
1776            guid2, owner_history, element, pset
1777        ));
1778
1779        pset
1780    }
1781
1782    /// Add a distribution port for electrical connection
1783    ///
1784    /// Creates an IfcDistributionPort to represent a power connection point on a light fixture.
1785    /// This enables circuit connectivity in Electrical MVD.
1786    pub fn add_distribution_port(
1787        &mut self,
1788        name: &str,
1789        owner_history: EntityRef,
1790        parent_element: EntityRef,
1791        flow_direction: FlowDirectionEnum,
1792        system_type: DistributionSystemEnum,
1793    ) -> EntityRef {
1794        let guid = self.generate_guid();
1795
1796        // Create the port
1797        let port = self.add_entity(format!(
1798            "IFCDISTRIBUTIONPORT('{}',{},'{}','Electrical connection port',$,$,{},{})",
1799            guid,
1800            owner_history,
1801            Self::escape_string(name),
1802            flow_direction.to_step(),
1803            system_type.to_step()
1804        ));
1805
1806        // Nest the port within the parent element
1807        let guid2 = self.generate_guid();
1808        self.add_entity(format!(
1809            "IFCRELNESTS('{}',{},$,$,{},({}))",
1810            guid2, owner_history, parent_element, port
1811        ));
1812
1813        port
1814    }
1815
1816    /// Add a lighting distribution system
1817    ///
1818    /// Creates an IfcDistributionSystem to group light fixtures into a lighting circuit.
1819    pub fn add_lighting_system(
1820        &mut self,
1821        name: &str,
1822        owner_history: EntityRef,
1823        building: EntityRef,
1824    ) -> EntityRef {
1825        let guid = self.generate_guid();
1826
1827        let system = self.add_entity(format!(
1828            "IFCDISTRIBUTIONSYSTEM('{}',{},'{}','Lighting distribution system',$,.LIGHTING.)",
1829            guid,
1830            owner_history,
1831            Self::escape_string(name)
1832        ));
1833
1834        // Reference the system in the building
1835        let guid2 = self.generate_guid();
1836        self.add_entity(format!(
1837            "IFCRELSERVICESBUILDINGS('{}',{},$,$,{},({}))",
1838            guid2, owner_history, system, building
1839        ));
1840
1841        system
1842    }
1843
1844    /// Assign elements to a distribution system (e.g., light fixtures to lighting circuit)
1845    pub fn assign_to_system(
1846        &mut self,
1847        owner_history: EntityRef,
1848        system: EntityRef,
1849        elements: &[EntityRef],
1850    ) {
1851        if elements.is_empty() {
1852            return;
1853        }
1854
1855        let guid = self.generate_guid();
1856        let element_list: Vec<String> = elements.iter().map(|e| e.to_string()).collect();
1857
1858        self.add_entity(format!(
1859            "IFCRELASSIGNSTOGROUP('{}',{},$,$,({}),$.PRODUCT.,{})",
1860            guid,
1861            owner_history,
1862            element_list.join(","),
1863            system
1864        ));
1865    }
1866
1867    /// Add variant-specific property set (Pset_LuminaireVariant)
1868    #[allow(clippy::too_many_arguments)]
1869    pub fn add_variant_pset(
1870        &mut self,
1871        element: EntityRef,
1872        owner_history: EntityRef,
1873        variant_id: &str,
1874        cct: Option<i32>,
1875        luminous_flux: Option<f64>,
1876        power: Option<f64>,
1877        cri: Option<i32>,
1878        weight: Option<f64>,
1879    ) -> EntityRef {
1880        let mut properties = Vec::new();
1881
1882        // GLDF Variant ID (for traceability)
1883        let variant_prop_str = format!(
1884            "IFCPROPERTYSINGLEVALUE('GLDF_VariantId',$,IFCLABEL('{}'),$)",
1885            Self::escape_string(variant_id)
1886        );
1887        let variant_prop = self.add_entity(variant_prop_str);
1888        properties.push(variant_prop);
1889
1890        // Color Temperature
1891        if let Some(temp) = cct {
1892            let prop_str = format!(
1893                "IFCPROPERTYSINGLEVALUE('ColorTemperature',$,IFCTHERMODYNAMICTEMPERATUREMEASURE({:.0}),$)",
1894                temp as f64
1895            );
1896            let prop = self.add_entity(prop_str);
1897            properties.push(prop);
1898        }
1899
1900        // Luminous Flux
1901        if let Some(flux) = luminous_flux {
1902            let prop_str = format!(
1903                "IFCPROPERTYSINGLEVALUE('LuminousFlux',$,IFCLUMINOUSFLUXMEASURE({:.1}),$)",
1904                flux
1905            );
1906            let prop = self.add_entity(prop_str);
1907            properties.push(prop);
1908        }
1909
1910        // Power
1911        if let Some(p) = power {
1912            let prop_str = format!(
1913                "IFCPROPERTYSINGLEVALUE('Power',$,IFCPOWERMEASURE({:.2}),$)",
1914                p
1915            );
1916            let prop = self.add_entity(prop_str);
1917            properties.push(prop);
1918        }
1919
1920        // Efficacy (calculated)
1921        if let (Some(flux), Some(p)) = (luminous_flux, power) {
1922            if p > 0.0 {
1923                let efficacy = flux / p;
1924                let prop_str = format!(
1925                    "IFCPROPERTYSINGLEVALUE('Efficacy',$,IFCREAL({:.1}),$)",
1926                    efficacy
1927                );
1928                let prop = self.add_entity(prop_str);
1929                properties.push(prop);
1930            }
1931        }
1932
1933        // Color Rendering Index
1934        if let Some(index) = cri {
1935            let prop_str = format!(
1936                "IFCPROPERTYSINGLEVALUE('ColorRenderingIndex',$,IFCINTEGER({}),$)",
1937                index
1938            );
1939            let prop = self.add_entity(prop_str);
1940            properties.push(prop);
1941        }
1942
1943        // Weight
1944        if let Some(w) = weight {
1945            let prop_str = format!(
1946                "IFCPROPERTYSINGLEVALUE('Weight',$,IFCMASSMEASURE({:.3}),$)",
1947                w
1948            );
1949            let prop = self.add_entity(prop_str);
1950            properties.push(prop);
1951        }
1952
1953        if properties.is_empty() {
1954            return EntityRef::new(0);
1955        }
1956
1957        let prop_list: Vec<String> = properties.iter().map(|p| p.to_string()).collect();
1958
1959        let guid = self.generate_guid();
1960        let pset_str = format!(
1961            "IFCPROPERTYSET('{}',{},'Pset_LuminaireVariant',$,({}))",
1962            guid,
1963            owner_history,
1964            prop_list.join(",")
1965        );
1966        let pset = self.add_entity(pset_str);
1967
1968        let guid2 = self.generate_guid();
1969        let rel_str = format!(
1970            "IFCRELDEFINESBYPROPERTIES('{}',{},$,$,({}),{})",
1971            guid2, owner_history, element, pset
1972        );
1973        self.add_entity(rel_str);
1974
1975        pset
1976    }
1977
1978    /// Clear light source tracking for a new fixture type
1979    pub fn clear_light_sources(&mut self) {
1980        self.light_source_ids.clear();
1981    }
1982
1983    /// Add photometry filenames property set (for roundtrip preservation)
1984    ///
1985    /// Stores original LDT filenames so they can be restored after IFC import.
1986    pub fn add_photometry_filenames_pset(
1987        &mut self,
1988        element: EntityRef,
1989        owner_history: EntityRef,
1990        filenames: &[String],
1991    ) -> EntityRef {
1992        let mut properties = Vec::new();
1993
1994        // Store each filename as a separate property
1995        for (i, filename) in filenames.iter().enumerate() {
1996            let prop_str = format!(
1997                "IFCPROPERTYSINGLEVALUE('PhotometryFile_{}',$,IFCLABEL('{}'),$)",
1998                i + 1,
1999                Self::escape_string(filename)
2000            );
2001            let prop = self.add_entity(prop_str);
2002            properties.push(prop);
2003        }
2004
2005        if properties.is_empty() {
2006            return EntityRef::new(0);
2007        }
2008
2009        let prop_list: Vec<String> = properties.iter().map(|p| p.to_string()).collect();
2010
2011        let guid = self.generate_guid();
2012        let pset_str = format!(
2013            "IFCPROPERTYSET('{}',{},'Pset_GLDF_PhotometryFiles',$,({}))",
2014            guid,
2015            owner_history,
2016            prop_list.join(",")
2017        );
2018        let pset = self.add_entity(pset_str);
2019
2020        let guid2 = self.generate_guid();
2021        let rel_str = format!(
2022            "IFCRELDEFINESBYPROPERTIES('{}',{},$,$,({}),{})",
2023            guid2, owner_history, element, pset
2024        );
2025        self.add_entity(rel_str);
2026
2027        pset
2028    }
2029
2030    /// Add GLDF descriptive attributes property set
2031    ///
2032    /// Stores GLDF-specific descriptive attributes for roundtrip preservation:
2033    /// - ElectricalSafetyClass
2034    /// - MedianUsefulLife
2035    /// - MountingType (Ceiling/Wall/etc.)
2036    /// - RecessedDepth
2037    pub fn add_gldf_descriptive_pset(
2038        &mut self,
2039        element: EntityRef,
2040        owner_history: EntityRef,
2041        safety_class: Option<&str>,
2042        median_useful_life: Option<&str>,
2043        mounting_type: Option<&str>,
2044        recessed_depth: Option<f64>,
2045    ) -> EntityRef {
2046        let mut properties = Vec::new();
2047
2048        if let Some(sc) = safety_class {
2049            let prop = self.add_entity(format!(
2050                "IFCPROPERTYSINGLEVALUE('ElectricalSafetyClass',$,IFCLABEL('{}'),$)",
2051                Self::escape_string(sc)
2052            ));
2053            properties.push(prop);
2054        }
2055
2056        if let Some(mul) = median_useful_life {
2057            let prop = self.add_entity(format!(
2058                "IFCPROPERTYSINGLEVALUE('MedianUsefulLife',$,IFCLABEL('{}'),$)",
2059                Self::escape_string(mul)
2060            ));
2061            properties.push(prop);
2062        }
2063
2064        if let Some(mt) = mounting_type {
2065            let prop = self.add_entity(format!(
2066                "IFCPROPERTYSINGLEVALUE('GLDF_MountingType',$,IFCLABEL('{}'),$)",
2067                Self::escape_string(mt)
2068            ));
2069            properties.push(prop);
2070        }
2071
2072        if let Some(rd) = recessed_depth {
2073            let prop = self.add_entity(format!(
2074                "IFCPROPERTYSINGLEVALUE('GLDF_RecessedDepth',$,IFCLENGTHMEASURE({:.1}),$)",
2075                rd
2076            ));
2077            properties.push(prop);
2078        }
2079
2080        if properties.is_empty() {
2081            return EntityRef::new(0);
2082        }
2083
2084        let prop_list: Vec<String> = properties.iter().map(|p| p.to_string()).collect();
2085
2086        let guid = self.generate_guid();
2087        let pset_str = format!(
2088            "IFCPROPERTYSET('{}',{},'Pset_GLDF_DescriptiveAttributes',$,({}))",
2089            guid,
2090            owner_history,
2091            prop_list.join(",")
2092        );
2093        let pset = self.add_entity(pset_str);
2094
2095        let guid2 = self.generate_guid();
2096        let rel_str = format!(
2097            "IFCRELDEFINESBYPROPERTIES('{}',{},$,$,({}),{})",
2098            guid2, owner_history, element, pset
2099        );
2100        self.add_entity(rel_str);
2101
2102        pset
2103    }
2104
2105    /// Add raw LDT file content as base64 for exact roundtrip preservation
2106    ///
2107    /// This stores the complete LDT file so it can be restored exactly.
2108    pub fn add_ldt_raw_content_pset(
2109        &mut self,
2110        element: EntityRef,
2111        owner_history: EntityRef,
2112        index: usize,
2113        filename: &str,
2114        raw_content: &str,
2115    ) -> EntityRef {
2116        use base64::{engine::general_purpose::STANDARD, Engine};
2117
2118        let encoded = STANDARD.encode(raw_content.as_bytes());
2119
2120        let filename_prop = self.add_entity(format!(
2121            "IFCPROPERTYSINGLEVALUE('LDT_{}_Filename',$,IFCLABEL('{}'),$)",
2122            index,
2123            Self::escape_string(filename)
2124        ));
2125
2126        let content_prop = self.add_entity(format!(
2127            "IFCPROPERTYSINGLEVALUE('LDT_{}_Content',$,IFCLABEL('{}'),$)",
2128            index,
2129            Self::escape_string(&encoded)
2130        ));
2131
2132        let guid = self.generate_guid();
2133        let pset_str = format!(
2134            "IFCPROPERTYSET('{}',{},'Pset_GLDF_LDTRawContent',$,({},{}))",
2135            guid, owner_history, filename_prop, content_prop
2136        );
2137        let pset = self.add_entity(pset_str);
2138
2139        let guid2 = self.generate_guid();
2140        let rel_str = format!(
2141            "IFCRELDEFINESBYPROPERTIES('{}',{},$,$,({}),{})",
2142            guid2, owner_history, element, pset
2143        );
2144        self.add_entity(rel_str);
2145
2146        pset
2147    }
2148
2149    /// Add LDT metadata property set (for roundtrip preservation)
2150    ///
2151    /// Stores original LDT file metadata (symmetry, Mc, Ng, Dc, Dg, DR values, etc.)
2152    /// so they can be restored after IFC import.
2153    #[allow(clippy::too_many_arguments)]
2154    pub fn add_ldt_metadata_pset(
2155        &mut self,
2156        element: EntityRef,
2157        owner_history: EntityRef,
2158        index: usize,
2159        symmetry: i32,
2160        num_c_planes: i32,
2161        num_g_angles: i32,
2162        dc: f64,
2163        dg: f64,
2164        total_flux: f64,
2165        dr: &[f64; 10],
2166        luminaire_name: &str,
2167    ) -> EntityRef {
2168        let mut properties = Vec::new();
2169
2170        // LDT index (for matching with photometry files)
2171        let idx_prop = self.add_entity(format!(
2172            "IFCPROPERTYSINGLEVALUE('LDT_{}_Index',$,IFCINTEGER({}),$)",
2173            index, index
2174        ));
2175        properties.push(idx_prop);
2176
2177        // Symmetry
2178        let sym_prop = self.add_entity(format!(
2179            "IFCPROPERTYSINGLEVALUE('LDT_{}_Symmetry',$,IFCINTEGER({}),$)",
2180            index, symmetry
2181        ));
2182        properties.push(sym_prop);
2183
2184        // Number of C-planes
2185        let mc_prop = self.add_entity(format!(
2186            "IFCPROPERTYSINGLEVALUE('LDT_{}_NumCPlanes',$,IFCINTEGER({}),$)",
2187            index, num_c_planes
2188        ));
2189        properties.push(mc_prop);
2190
2191        // Number of gamma angles
2192        let ng_prop = self.add_entity(format!(
2193            "IFCPROPERTYSINGLEVALUE('LDT_{}_NumGAngles',$,IFCINTEGER({}),$)",
2194            index, num_g_angles
2195        ));
2196        properties.push(ng_prop);
2197
2198        // C-plane distance (Dc)
2199        let dc_prop = self.add_entity(format!(
2200            "IFCPROPERTYSINGLEVALUE('LDT_{}_Dc',$,IFCREAL({:.2}),$)",
2201            index, dc
2202        ));
2203        properties.push(dc_prop);
2204
2205        // Gamma distance (Dg)
2206        let dg_prop = self.add_entity(format!(
2207            "IFCPROPERTYSINGLEVALUE('LDT_{}_Dg',$,IFCREAL({:.2}),$)",
2208            index, dg
2209        ));
2210        properties.push(dg_prop);
2211
2212        // Total flux
2213        let flux_prop = self.add_entity(format!(
2214            "IFCPROPERTYSINGLEVALUE('LDT_{}_TotalFlux',$,IFCLUMINOUSFLUXMEASURE({:.1}),$)",
2215            index, total_flux
2216        ));
2217        properties.push(flux_prop);
2218
2219        // DR values (as comma-separated string)
2220        let dr_str = dr
2221            .iter()
2222            .map(|v| format!("{:.5}", v))
2223            .collect::<Vec<_>>()
2224            .join(",");
2225        let dr_prop = self.add_entity(format!(
2226            "IFCPROPERTYSINGLEVALUE('LDT_{}_DR',$,IFCLABEL('{}'),$)",
2227            index,
2228            Self::escape_string(&dr_str)
2229        ));
2230        properties.push(dr_prop);
2231
2232        // Luminaire name
2233        let name_prop = self.add_entity(format!(
2234            "IFCPROPERTYSINGLEVALUE('LDT_{}_LuminaireName',$,IFCLABEL('{}'),$)",
2235            index,
2236            Self::escape_string(luminaire_name)
2237        ));
2238        properties.push(name_prop);
2239
2240        let prop_list: Vec<String> = properties.iter().map(|p| p.to_string()).collect();
2241
2242        let guid = self.generate_guid();
2243        let pset_str = format!(
2244            "IFCPROPERTYSET('{}',{},'Pset_GLDF_LDTMetadata',$,({}))",
2245            guid,
2246            owner_history,
2247            prop_list.join(",")
2248        );
2249        let pset = self.add_entity(pset_str);
2250
2251        let guid2 = self.generate_guid();
2252        let rel_str = format!(
2253            "IFCRELDEFINESBYPROPERTIES('{}',{},$,$,({}),{})",
2254            guid2, owner_history, element, pset
2255        );
2256        self.add_entity(rel_str);
2257
2258        pset
2259    }
2260
2261    /// Add a generic GLDF file as base64-encoded property set
2262    ///
2263    /// Used for preserving product images, sensor files, etc. through IFC roundtrip.
2264    pub fn add_gldf_file_pset(
2265        &mut self,
2266        project: EntityRef,
2267        owner_history: EntityRef,
2268        file_type: &str, // "image", "sensor", etc.
2269        filename: &str,
2270        content_type: &str, // "image/png", "sensor/sensldt", etc.
2271        content: &[u8],
2272    ) -> EntityRef {
2273        use base64::{engine::general_purpose::STANDARD, Engine};
2274
2275        let encoded = STANDARD.encode(content);
2276
2277        let type_prop = self.add_entity(format!(
2278            "IFCPROPERTYSINGLEVALUE('GLDF_FileType',$,IFCLABEL('{}'),$)",
2279            Self::escape_string(file_type)
2280        ));
2281
2282        let filename_prop = self.add_entity(format!(
2283            "IFCPROPERTYSINGLEVALUE('GLDF_Filename',$,IFCLABEL('{}'),$)",
2284            Self::escape_string(filename)
2285        ));
2286
2287        let content_type_prop = self.add_entity(format!(
2288            "IFCPROPERTYSINGLEVALUE('GLDF_ContentType',$,IFCLABEL('{}'),$)",
2289            Self::escape_string(content_type)
2290        ));
2291
2292        let content_prop = self.add_entity(format!(
2293            "IFCPROPERTYSINGLEVALUE('GLDF_FileContent',$,IFCLABEL('{}'),$)",
2294            Self::escape_string(&encoded)
2295        ));
2296
2297        let guid = self.generate_guid();
2298        let pset_str = format!(
2299            "IFCPROPERTYSET('{}',{},'Pset_GLDF_EmbeddedFile',$,({},{},{},{}))",
2300            guid, owner_history, type_prop, filename_prop, content_type_prop, content_prop
2301        );
2302        let pset = self.add_entity(pset_str);
2303
2304        let guid2 = self.generate_guid();
2305        let rel_str = format!(
2306            "IFCRELDEFINESBYPROPERTIES('{}',{},$,$,({}),{})",
2307            guid2, owner_history, project, pset
2308        );
2309        self.add_entity(rel_str);
2310
2311        pset
2312    }
2313
2314    // =========================================================================
2315    // Output
2316    // =========================================================================
2317
2318    /// Get current Unix timestamp
2319    fn current_timestamp() -> i64 {
2320        #[cfg(not(target_arch = "wasm32"))]
2321        {
2322            use std::time::{SystemTime, UNIX_EPOCH};
2323            SystemTime::now()
2324                .duration_since(UNIX_EPOCH)
2325                .map(|d| d.as_secs() as i64)
2326                .unwrap_or(0)
2327        }
2328
2329        #[cfg(target_arch = "wasm32")]
2330        {
2331            1704067200_i64
2332        }
2333    }
2334
2335    /// Generate the complete IFC STEP file content
2336    pub fn to_step_string(&self) -> String {
2337        #[cfg(not(target_arch = "wasm32"))]
2338        let timestamp = {
2339            let now: DateTime<Utc> = Utc::now();
2340            now.format("%Y-%m-%dT%H:%M:%S").to_string()
2341        };
2342
2343        #[cfg(target_arch = "wasm32")]
2344        let timestamp = "2024-01-01T00:00:00".to_string();
2345
2346        let mut output = String::new();
2347
2348        // HEADER section
2349        output.push_str("ISO-10303-21;\n");
2350        output.push_str("HEADER;\n");
2351        output.push_str("FILE_DESCRIPTION(('GLDF to IFC Export'),'2;1');\n");
2352        output.push_str(&format!(
2353            "FILE_NAME('export.ifc','{}',(''),(''),'gldf-rs','gldf-rs','');\n",
2354            timestamp
2355        ));
2356        output.push_str(&format!("FILE_SCHEMA(('{}'));\n", self.schema));
2357        output.push_str("ENDSEC;\n");
2358        output.push('\n');
2359
2360        // DATA section
2361        output.push_str("DATA;\n");
2362        for entity in &self.entities {
2363            output.push_str(entity);
2364            output.push_str(";\n");
2365        }
2366        output.push_str("ENDSEC;\n");
2367        output.push('\n');
2368
2369        // End
2370        output.push_str("END-ISO-10303-21;\n");
2371
2372        output
2373    }
2374}
2375
2376#[cfg(test)]
2377mod tests {
2378    use super::*;
2379
2380    #[test]
2381    fn test_minimal_ifc() {
2382        let mut writer = StepWriter::new("IFC4");
2383        let oh = writer.add_owner_history("Test Corp");
2384        let project = writer.add_project("Test Project", oh);
2385        let site = writer.add_site("Test Site", oh, project);
2386        let building = writer.add_building("Test Building", oh, site);
2387        let storey = writer.add_storey("Ground Floor", oh, building);
2388
2389        let fixture_type = writer.add_light_fixture_type(
2390            "LED Downlight",
2391            "Test Corp",
2392            LightFixtureTypeEnum::PointSource,
2393            oh,
2394        );
2395
2396        let _fixture =
2397            writer.add_light_fixture("LED Downlight 001", oh, storey, Some(fixture_type), None);
2398
2399        let output = writer.to_step_string();
2400
2401        // Verify structure
2402        assert!(output.contains("ISO-10303-21"));
2403        assert!(output.contains("IFC4"));
2404        assert!(output.contains("IFCPROJECT"));
2405        assert!(output.contains("IFCSITE"));
2406        assert!(output.contains("IFCBUILDING"));
2407        assert!(output.contains("IFCBUILDINGSTOREY"));
2408        assert!(output.contains("IFCLIGHTFIXTURETYPE"));
2409        assert!(output.contains("IFCLIGHTFIXTURE"));
2410        assert!(output.contains("Pset_ManufacturerTypeInformation"));
2411
2412        // Verify no inline entities (Issue 1)
2413        assert!(!output.contains("IFCLOCALPLACEMENT($,IFCAXIS2PLACEMENT3D"));
2414
2415        // When fixture_type is provided without mesh_data, instance geometry should be $
2416        // (inheriting from type), not a separate geometry definition
2417        // Note: The type itself doesn't have geometry via add_light_fixture_type() without rep map,
2418        // but the instance correctly uses $ for representation when type is provided
2419
2420        println!("{}", output);
2421    }
2422
2423    #[test]
2424    fn test_light_source_goniometric() {
2425        let mut writer = StepWriter::new("IFC4");
2426        let oh = writer.add_owner_history("Test Corp");
2427        let project = writer.add_project("Test Project", oh);
2428        let site = writer.add_site("Test Site", oh, project);
2429        let building = writer.add_building("Test Building", oh, site);
2430        let storey = writer.add_storey("Ground Floor", oh, building);
2431
2432        let fixture_type = writer.add_light_fixture_type(
2433            "LED Panel",
2434            "Test Corp",
2435            LightFixtureTypeEnum::DirectionSource,
2436            oh,
2437        );
2438
2439        // Add goniometric light source
2440        let _light_source = writer.add_light_source_goniometric(
2441            "LED Panel Light Source",
2442            Some((1.0, 0.95, 0.9)),
2443            3000.0,
2444            4500.0,
2445            LightEmissionSourceEnum::Led,
2446            Some("photometry/led_panel.ldt"),
2447        );
2448
2449        // Add common property set
2450        writer.add_light_fixture_common_pset(
2451            fixture_type,
2452            oh,
2453            Some(1),
2454            Some(36.0),
2455            Some("SURFACE"),
2456            Some("CEILING"),
2457        );
2458
2459        // Add electrical property set
2460        writer.add_electrical_pset(
2461            fixture_type,
2462            oh,
2463            Some(230.0),
2464            Some(0.16),
2465            Some(0.95),
2466            Some("IP20"),
2467        );
2468
2469        let _fixture =
2470            writer.add_light_fixture("LED Panel 001", oh, storey, Some(fixture_type), None);
2471
2472        let output = writer.to_step_string();
2473
2474        // Verify goniometric light source
2475        assert!(output.contains("IFCLIGHTSOURCEGONIOMETRIC"));
2476        assert!(output.contains("led_panel.ldt"));
2477        assert!(output.contains("3000.0"));
2478        assert!(output.contains("4500.0"));
2479        assert!(output.contains(".LED."));
2480
2481        // Verify property sets
2482        assert!(output.contains("Pset_LightFixtureTypeCommon"));
2483        assert!(output.contains("NumberOfSources"));
2484        assert!(output.contains("TotalWattage"));
2485
2486        assert!(output.contains("Pset_ElectricalDeviceCommon"));
2487        assert!(output.contains("RatedVoltage"));
2488        assert!(output.contains("PowerFactor"));
2489        assert!(output.contains("IPCode"));
2490
2491        // Verify IP code is correct (Issue 5)
2492        assert!(output.contains("IFCLABEL('IP20')"));
2493        assert!(!output.contains("IPIP20"));
2494
2495        // Verify light source is connected (Issue 2)
2496        assert!(output.contains("IFCRELASSIGNSTOGROUP"));
2497
2498        println!("{}", output);
2499    }
2500
2501    #[test]
2502    fn test_embedded_light_distribution() {
2503        let mut writer = StepWriter::new("IFC4");
2504
2505        let distribution_data: Vec<(f64, f64, f64)> = vec![
2506            (0.0, 0.0, 1.0),
2507            (0.0, 30.0, 0.866),
2508            (0.0, 60.0, 0.5),
2509            (0.0, 90.0, 0.0),
2510        ];
2511
2512        let _dist = writer.add_light_intensity_distribution("TYPE_C", &distribution_data);
2513
2514        let output = writer.to_step_string();
2515        assert!(output.contains("IFCLIGHTINTENSITYDISTRIBUTION"));
2516        assert!(output.contains("IFCLIGHTDISTRIBUTIONDATA"));
2517        assert!(output.contains(".TYPE_C."));
2518    }
2519
2520    #[test]
2521    fn test_guid_format() {
2522        let mut writer = StepWriter::new("IFC4");
2523
2524        for _ in 0..10 {
2525            let guid = writer.generate_guid();
2526            assert_eq!(
2527                guid.len(),
2528                22,
2529                "GUID should be exactly 22 characters: {}",
2530                guid
2531            );
2532
2533            for c in guid.chars() {
2534                assert!(
2535                    c.is_ascii_alphanumeric() || c == '_' || c == '$',
2536                    "Invalid character in GUID: {} (full: {})",
2537                    c,
2538                    guid
2539                );
2540            }
2541        }
2542    }
2543
2544    #[test]
2545    fn test_ip_code_normalization() {
2546        let mut writer = StepWriter::new("IFC4");
2547        let oh = writer.add_owner_history("Test");
2548        let project = writer.add_project("Test", oh);
2549        let site = writer.add_site("Site", oh, project);
2550        let building = writer.add_building("Building", oh, site);
2551        let _storey = writer.add_storey("Storey", oh, building);
2552
2553        let fixture_type =
2554            writer.add_light_fixture_type("Test", "Test", LightFixtureTypeEnum::NotDefined, oh);
2555
2556        // Test with "IP20" input
2557        writer.add_electrical_pset(fixture_type, oh, None, None, None, Some("IP20"));
2558        let output = writer.to_step_string();
2559        assert!(output.contains("IFCLABEL('IP20')"));
2560        assert!(!output.contains("IPIP"));
2561
2562        // Test with "20" input (no prefix)
2563        let mut writer2 = StepWriter::new("IFC4");
2564        let oh2 = writer2.add_owner_history("Test");
2565        let project2 = writer2.add_project("Test", oh2);
2566        let site2 = writer2.add_site("Site", oh2, project2);
2567        let building2 = writer2.add_building("Building", oh2, site2);
2568        let _storey2 = writer2.add_storey("Storey", oh2, building2);
2569        let fixture_type2 =
2570            writer2.add_light_fixture_type("Test", "Test", LightFixtureTypeEnum::NotDefined, oh2);
2571        writer2.add_electrical_pset(fixture_type2, oh2, None, None, None, Some("20"));
2572        let output2 = writer2.to_step_string();
2573        assert!(output2.contains("IFCLABEL('IP20')"));
2574    }
2575}