Skip to main content

bimifc_bevy/
loader.rs

1//! IFC file loading - handles file dialog, drag-and-drop, and WASM file input
2
3use crate::mesh::IfcMesh;
4use crate::{EntityInfo, IfcSceneData};
5use bevy::prelude::*;
6#[cfg(all(not(target_arch = "wasm32"), not(target_os = "ios"),))]
7use bevy::tasks::IoTaskPool;
8use bevy::tasks::Task;
9use bimifc_geometry::GeometryRouter;
10use bimifc_model::{AttributeValue, EntityId, EntityResolver, IfcModel, IfcType};
11use bimifc_parser::{EntityScanner, ParsedModel};
12use rustc_hash::FxHashMap;
13use std::path::PathBuf;
14use std::sync::Arc;
15
16#[cfg(target_arch = "wasm32")]
17use wasm_bindgen::prelude::*;
18#[cfg(target_arch = "wasm32")]
19use wasm_bindgen::JsCast;
20
21/// Plugin for file loading functionality
22pub struct LoaderPlugin;
23
24impl Plugin for LoaderPlugin {
25    fn build(&self, app: &mut App) {
26        app.add_message::<LoadIfcFileEvent>()
27            .add_message::<LoadIfcContentEvent>()
28            .add_message::<IfcFileLoadedEvent>()
29            .add_message::<OpenFileDialogRequest>()
30            .init_resource::<FileDialogState>()
31            .add_systems(
32                Update,
33                (
34                    handle_open_dialog_request,
35                    poll_file_dialog,
36                    poll_wasm_file_input,
37                    handle_load_file_event,
38                    handle_load_content_event,
39                    handle_file_drop,
40                ),
41            );
42
43        // On WASM, setup file input handling
44        #[cfg(target_arch = "wasm32")]
45        {
46            setup_wasm_file_input();
47        }
48    }
49}
50
51/// System to poll WASM file input for pending files
52fn poll_wasm_file_input(mut content_events: MessageWriter<LoadIfcContentEvent>) {
53    if let Some((file_name, content)) = poll_pending_file() {
54        content_events.write(LoadIfcContentEvent { file_name, content });
55    }
56}
57
58/// Message to request opening a file dialog
59#[derive(Message)]
60pub struct OpenFileDialogRequest;
61
62/// State for tracking async file dialog
63#[derive(Resource, Default)]
64pub struct FileDialogState {
65    task: Option<Task<Option<PathBuf>>>,
66}
67
68/// Message to trigger file loading from path (native)
69#[derive(Message)]
70pub struct LoadIfcFileEvent {
71    pub path: std::path::PathBuf,
72}
73
74/// Message to trigger file loading from content (WASM)
75#[derive(Message)]
76pub struct LoadIfcContentEvent {
77    pub file_name: String,
78    pub content: String,
79}
80
81/// Message emitted when file loading completes
82#[derive(Message)]
83pub struct IfcFileLoadedEvent {
84    pub path: PathBuf,
85    pub entity_count: usize,
86    pub mesh_count: usize,
87}
88
89/// System to handle request to open file dialog (spawns async task)
90#[cfg(all(not(target_arch = "wasm32"), not(target_os = "ios"),))]
91fn handle_open_dialog_request(
92    mut requests: MessageReader<OpenFileDialogRequest>,
93    mut state: ResMut<FileDialogState>,
94) {
95    for _ in requests.read() {
96        // Don't spawn another dialog if one is already pending
97        if state.task.is_some() {
98            crate::log("[Loader] File dialog already open");
99            continue;
100        }
101
102        crate::log_info("[Loader] Opening file dialog...");
103
104        let task_pool = IoTaskPool::get();
105        let task = task_pool.spawn(async {
106            use rfd::AsyncFileDialog;
107
108            let file = AsyncFileDialog::new()
109                .add_filter("IFC Files", &["ifc", "IFC"])
110                .set_title("Open IFC File")
111                .pick_file()
112                .await;
113
114            file.map(|f| f.path().to_path_buf())
115        });
116
117        state.task = Some(task);
118    }
119}
120
121/// Handle file dialog request on WASM - trigger file input
122#[cfg(target_arch = "wasm32")]
123fn handle_open_dialog_request(
124    mut requests: MessageReader<OpenFileDialogRequest>,
125    _state: ResMut<FileDialogState>,
126) {
127    for _ in requests.read() {
128        crate::log_info("[Loader] Opening file dialog (WASM)...");
129        trigger_file_dialog();
130    }
131}
132
133/// Stub for iOS - file dialog handled by native UI
134#[cfg(all(not(target_arch = "wasm32"), target_os = "ios",))]
135fn handle_open_dialog_request(
136    mut _requests: MessageReader<OpenFileDialogRequest>,
137    mut _state: ResMut<FileDialogState>,
138) {
139    // File dialog handled by native UI on these platforms
140}
141
142/// System to poll async file dialog result
143fn poll_file_dialog(
144    mut state: ResMut<FileDialogState>,
145    mut load_events: MessageWriter<LoadIfcFileEvent>,
146) {
147    if let Some(ref mut task) = state.task {
148        if let Some(result) = bevy::tasks::block_on(bevy::tasks::poll_once(task)) {
149            if let Some(path) = result {
150                crate::log_info(&format!("[Loader] File selected: {:?}", path));
151                load_events.write(LoadIfcFileEvent { path });
152            } else {
153                crate::log("[Loader] File dialog cancelled");
154            }
155            state.task = None;
156        }
157    }
158}
159
160/// System to handle file load events
161fn handle_load_file_event(
162    mut events: MessageReader<LoadIfcFileEvent>,
163    mut scene_data: ResMut<IfcSceneData>,
164    mut auto_fit: ResMut<crate::mesh::AutoFitState>,
165    mut loaded_events: MessageWriter<IfcFileLoadedEvent>,
166) {
167    for event in events.read() {
168        crate::log_info(&format!("[Loader] Loading file: {:?}", event.path));
169
170        match load_ifc_file(&event.path) {
171            Ok((meshes, entities)) => {
172                let mesh_count = meshes.len();
173                let entity_count = entities.len();
174
175                crate::log_info(&format!(
176                    "[Loader] Loaded {} meshes, {} entities",
177                    mesh_count, entity_count
178                ));
179
180                // Update scene data
181                scene_data.meshes = meshes;
182                scene_data.entities = entities;
183                scene_data.dirty = true;
184                scene_data.bounds = None;
185
186                // Reset auto-fit to trigger camera adjustment
187                auto_fit.has_fit = false;
188
189                loaded_events.write(IfcFileLoadedEvent {
190                    path: event.path.clone(),
191                    entity_count,
192                    mesh_count,
193                });
194            }
195            Err(e) => {
196                crate::log_info(&format!("[Loader] Error loading file: {}", e));
197            }
198        }
199    }
200}
201
202/// System to handle drag-and-drop files
203fn handle_file_drop(
204    mut file_drag_drop_events: MessageReader<bevy::window::FileDragAndDrop>,
205    mut load_events: MessageWriter<LoadIfcFileEvent>,
206) {
207    for event in file_drag_drop_events.read() {
208        if let bevy::window::FileDragAndDrop::DroppedFile { path_buf, .. } = event {
209            // Check if it's an IFC file
210            if let Some(ext) = path_buf.extension() {
211                if ext.eq_ignore_ascii_case("ifc") {
212                    crate::log_info(&format!("[Loader] File dropped: {:?}", path_buf));
213                    load_events.write(LoadIfcFileEvent {
214                        path: path_buf.clone(),
215                    });
216                }
217            }
218        }
219    }
220}
221
222/// System to handle content load events (WASM - content comes from JS file input)
223fn handle_load_content_event(
224    mut events: MessageReader<LoadIfcContentEvent>,
225    mut scene_data: ResMut<IfcSceneData>,
226    mut auto_fit: ResMut<crate::mesh::AutoFitState>,
227    mut loaded_events: MessageWriter<IfcFileLoadedEvent>,
228) {
229    for event in events.read() {
230        crate::log_info(&format!(
231            "[Loader] Loading content: {} ({:.2} MB)",
232            event.file_name,
233            event.content.len() as f64 / (1024.0 * 1024.0)
234        ));
235
236        match load_ifc_content(&event.content) {
237            Ok((meshes, entities)) => {
238                let mesh_count = meshes.len();
239                let entity_count = entities.len();
240
241                crate::log_info(&format!(
242                    "[Loader] Loaded {} meshes, {} entities",
243                    mesh_count, entity_count
244                ));
245
246                // Update scene data
247                scene_data.meshes = meshes;
248                scene_data.entities = entities;
249                scene_data.dirty = true;
250                scene_data.bounds = None;
251
252                // Reset auto-fit to trigger camera adjustment
253                auto_fit.has_fit = false;
254
255                loaded_events.write(IfcFileLoadedEvent {
256                    path: PathBuf::from(&event.file_name),
257                    entity_count,
258                    mesh_count,
259                });
260            }
261            Err(e) => {
262                crate::log_info(&format!("[Loader] Error loading content: {}", e));
263            }
264        }
265    }
266}
267
268// ============================================================================
269// WASM File Input Support
270// ============================================================================
271
272#[cfg(target_arch = "wasm32")]
273mod wasm_file_input {
274    use super::*;
275    use std::sync::Mutex;
276    use wasm_bindgen::closure::Closure;
277
278    // Global storage for pending file content (set by JS callback, read by Bevy system)
279    static PENDING_FILE: Mutex<Option<(String, String)>> = Mutex::new(None);
280
281    /// Setup WASM file input - creates hidden input element and exposes JS API
282    pub fn setup_wasm_file_input() {
283        // Create file input element
284        let window = match web_sys::window() {
285            Some(w) => w,
286            None => return,
287        };
288        let document = match window.document() {
289            Some(d) => d,
290            None => return,
291        };
292
293        // Create hidden file input
294        let input: web_sys::HtmlInputElement = match document.create_element("input") {
295            Ok(el) => match el.dyn_into() {
296                Ok(i) => i,
297                Err(_) => return,
298            },
299            Err(_) => return,
300        };
301
302        input.set_type("file");
303        input.set_accept(".ifc,.IFC");
304        input.set_id("bevy-file-input");
305        input.style().set_property("display", "none").ok();
306
307        // Add to document
308        if let Some(body) = document.body() {
309            let _ = body.append_child(&input);
310        }
311
312        // Set up change handler
313        let closure = Closure::wrap(Box::new(move |event: web_sys::Event| {
314            let input: web_sys::HtmlInputElement = match event.target() {
315                Some(t) => match t.dyn_into() {
316                    Ok(i) => i,
317                    Err(_) => return,
318                },
319                None => return,
320            };
321
322            let files = match input.files() {
323                Some(f) => f,
324                None => return,
325            };
326
327            let file = match files.get(0) {
328                Some(f) => f,
329                None => return,
330            };
331
332            let file_name = file.name();
333            crate::log_info(&format!("[WASM] File selected: {}", file_name));
334
335            // Read file using FileReader
336            let reader = match web_sys::FileReader::new() {
337                Ok(r) => r,
338                Err(_) => return,
339            };
340
341            let reader_clone = reader.clone();
342            let file_name_clone = file_name.clone();
343
344            let onload = Closure::wrap(Box::new(move |_: web_sys::Event| {
345                let result = match reader_clone.result() {
346                    Ok(r) => r,
347                    Err(_) => return,
348                };
349
350                let content = match result.as_string() {
351                    Some(s) => s,
352                    None => return,
353                };
354
355                crate::log_info(&format!("[WASM] File read: {} bytes", content.len()));
356
357                // Store in global for Bevy to pick up
358                if let Ok(mut pending) = PENDING_FILE.lock() {
359                    *pending = Some((file_name_clone.clone(), content));
360                }
361            }) as Box<dyn FnMut(_)>);
362
363            reader.set_onload(Some(onload.as_ref().unchecked_ref()));
364            onload.forget(); // Leak closure to keep it alive
365
366            let _ = reader.read_as_text(&file);
367
368            // Clear input so same file can be selected again
369            input.set_value("");
370        }) as Box<dyn FnMut(_)>);
371
372        input.set_onchange(Some(closure.as_ref().unchecked_ref()));
373        closure.forget(); // Leak closure to keep it alive
374
375        crate::log("[WASM] File input element created");
376    }
377
378    /// Check for pending file content and emit load event
379    pub fn poll_pending_file() -> Option<(String, String)> {
380        if let Ok(mut pending) = PENDING_FILE.lock() {
381            pending.take()
382        } else {
383            None
384        }
385    }
386
387    /// Trigger file input dialog from JS
388    pub fn trigger_file_dialog() {
389        let window = match web_sys::window() {
390            Some(w) => w,
391            None => return,
392        };
393        let document = match window.document() {
394            Some(d) => d,
395            None => return,
396        };
397
398        if let Some(input) = document.get_element_by_id("bevy-file-input") {
399            if let Ok(input) = input.dyn_into::<web_sys::HtmlInputElement>() {
400                input.click();
401            }
402        }
403    }
404}
405
406#[cfg(target_arch = "wasm32")]
407pub use wasm_file_input::*;
408
409#[cfg(not(target_arch = "wasm32"))]
410#[allow(dead_code)]
411fn setup_wasm_file_input() {
412    // No-op on native
413}
414
415#[cfg(not(target_arch = "wasm32"))]
416pub fn poll_pending_file() -> Option<(String, String)> {
417    None
418}
419
420#[cfg(not(target_arch = "wasm32"))]
421pub fn trigger_file_dialog() {
422    // No-op on native - use rfd instead
423}
424
425/// Load an IFC file and convert to viewer format
426fn load_ifc_file(
427    path: &std::path::Path,
428) -> Result<(Vec<IfcMesh>, Vec<EntityInfo>), Box<dyn std::error::Error>> {
429    // Read file content
430    let content = std::fs::read_to_string(path)?;
431
432    // Parse using trait-based parser
433    // Arguments: content, build_spatial (false for now), extract_properties (false)
434    let model = Arc::new(ParsedModel::parse(&content, false, false)?);
435
436    // Create geometry router with default processors and unit scale from model
437    let unit_scale = model.unit_scale();
438    let router = GeometryRouter::with_default_processors_and_unit_scale(unit_scale);
439
440    // Get resolver for entity lookups
441    let resolver = model.resolver();
442
443    // Collect building elements and their info
444    let mut meshes = Vec::new();
445    let mut entities = Vec::new();
446
447    // PERFORMANCE: Use scanner for fast initial pass to find building elements
448    // This avoids decoding all entities just to check their type
449    let mut scanner = EntityScanner::new(&content);
450    let mut element_ids: Vec<(u32, String)> = Vec::new();
451
452    while let Some((id, type_name, _, _)) = scanner.next_entity() {
453        // Fast check using type name string (no entity decoding needed)
454        if has_geometry_type_name(type_name) {
455            element_ids.push((id, type_name.to_string()));
456        }
457    }
458
459    crate::log_info(&format!(
460        "[Loader] Found {} building elements",
461        element_ids.len()
462    ));
463
464    // Build styled item color map for IFC-authored surface colors
465    let styled_colors = build_styled_item_colors(resolver);
466    if !styled_colors.is_empty() {
467        crate::log_info(&format!(
468            "[Loader] Found {} styled item colors",
469            styled_colors.len()
470        ));
471    }
472
473    // Process each element - only NOW do we decode entities
474    for (id, type_name) in element_ids {
475        // Get the decoded entity (lazy decode)
476        let entity = match resolver.get(EntityId(id)) {
477            Some(e) => e,
478            None => continue,
479        };
480
481        // Get entity name (attribute 2 for most building elements)
482        let name: Option<String> = entity.get_string(2).map(|s: &str| s.to_string());
483
484        // Process geometry
485        let mesh = match router.process_element(&entity, resolver) {
486            Ok(m) => m,
487            Err(e) => {
488                crate::log(&format!(
489                    "[Loader] Failed to process #{} ({}): {}",
490                    id, type_name, e
491                ));
492                continue;
493            }
494        };
495
496        // Prefer IFC-authored surface color over type-based default
497        let color = get_entity_surface_color(&entity, resolver, &styled_colors)
498            .unwrap_or_else(|| crate::mesh::get_default_color(&type_name));
499
500        if mesh.is_empty() {
501            // For point-placed entities like IfcLightFixture, generate a marker sphere
502            if is_point_entity_type(&type_name) {
503                if let Some(pos) = extract_entity_position(&entity, resolver, unit_scale) {
504                    let marker = create_marker_sphere(pos, 0.5);
505                    let ifc_mesh = IfcMesh::from_geometry_mesh(
506                        id as u64,
507                        marker,
508                        color,
509                        type_name.clone(),
510                        name.clone(),
511                    );
512                    meshes.push(ifc_mesh);
513                    entities.push(EntityInfo {
514                        id: id as u64,
515                        entity_type: type_name,
516                        name,
517                        storey: None,
518                        storey_elevation: None,
519                    });
520                }
521            }
522            continue;
523        }
524
525        // Convert to IfcMesh format - takes ownership of mesh, no cloning!
526        let ifc_mesh = IfcMesh::from_geometry_mesh(
527            id as u64,
528            mesh, // Move, not clone
529            color,
530            type_name.clone(),
531            name.clone(),
532        );
533        meshes.push(ifc_mesh);
534
535        // Add entity info
536        entities.push(EntityInfo {
537            id: id as u64,
538            entity_type: type_name,
539            name,
540            storey: None, // TODO: extract from spatial structure
541            storey_elevation: None,
542        });
543    }
544
545    Ok((meshes, entities))
546}
547
548/// Load IFC from content string (for WASM where we get content from JS)
549fn load_ifc_content(
550    content: &str,
551) -> Result<(Vec<IfcMesh>, Vec<EntityInfo>), Box<dyn std::error::Error>> {
552    // Parse using trait-based parser
553    // Arguments: content, build_spatial (false for now), extract_properties (false)
554    let model = Arc::new(ParsedModel::parse(content, false, false)?);
555
556    // Create geometry router with default processors and unit scale from model
557    let unit_scale = model.unit_scale();
558    let router = GeometryRouter::with_default_processors_and_unit_scale(unit_scale);
559
560    // Get resolver for entity lookups
561    let resolver = model.resolver();
562
563    // Collect building elements and their info
564    let mut meshes = Vec::new();
565    let mut entities = Vec::new();
566
567    // PERFORMANCE: Use scanner for fast initial pass to find building elements
568    // This avoids decoding all entities just to check their type
569    let mut scanner = EntityScanner::new(content);
570    let mut element_ids: Vec<(u32, String)> = Vec::new();
571
572    while let Some((id, type_name, _, _)) = scanner.next_entity() {
573        // Fast check using type name string (no entity decoding needed)
574        if has_geometry_type_name(type_name) {
575            element_ids.push((id, type_name.to_string()));
576        }
577    }
578
579    crate::log_info(&format!(
580        "[Loader] Found {} building elements",
581        element_ids.len()
582    ));
583
584    // Build styled item color map for IFC-authored surface colors
585    let styled_colors = build_styled_item_colors(resolver);
586    if !styled_colors.is_empty() {
587        crate::log_info(&format!(
588            "[Loader] Found {} styled item colors",
589            styled_colors.len()
590        ));
591    }
592
593    // Process each element - only NOW do we decode entities
594    for (id, type_name) in element_ids {
595        // Get the decoded entity (lazy decode)
596        let entity = match resolver.get(EntityId(id)) {
597            Some(e) => e,
598            None => continue,
599        };
600
601        // Get entity name (attribute 2 for most building elements)
602        let name: Option<String> = entity.get_string(2).map(|s: &str| s.to_string());
603
604        // Process geometry
605        let mesh = match router.process_element(&entity, resolver) {
606            Ok(m) => m,
607            Err(e) => {
608                crate::log(&format!(
609                    "[Loader] Failed to process #{} ({}): {}",
610                    id, type_name, e
611                ));
612                continue;
613            }
614        };
615
616        // Prefer IFC-authored surface color over type-based default
617        let color = get_entity_surface_color(&entity, resolver, &styled_colors)
618            .unwrap_or_else(|| crate::mesh::get_default_color(&type_name));
619
620        if mesh.is_empty() {
621            // For point-placed entities like IfcLightFixture, generate a marker sphere
622            if is_point_entity_type(&type_name) {
623                if let Some(pos) = extract_entity_position(&entity, resolver, unit_scale) {
624                    let marker = create_marker_sphere(pos, 0.5);
625                    let ifc_mesh = IfcMesh::from_geometry_mesh(
626                        id as u64,
627                        marker,
628                        color,
629                        type_name.clone(),
630                        name.clone(),
631                    );
632                    meshes.push(ifc_mesh);
633                    entities.push(EntityInfo {
634                        id: id as u64,
635                        entity_type: type_name,
636                        name,
637                        storey: None,
638                        storey_elevation: None,
639                    });
640                }
641            }
642            continue;
643        }
644
645        // Convert to IfcMesh format - takes ownership of mesh, no cloning!
646        let ifc_mesh = IfcMesh::from_geometry_mesh(
647            id as u64,
648            mesh, // Move, not clone
649            color,
650            type_name.clone(),
651            name.clone(),
652        );
653        meshes.push(ifc_mesh);
654
655        // Add entity info
656        entities.push(EntityInfo {
657            id: id as u64,
658            entity_type: type_name,
659            name,
660            storey: None, // TODO: extract from spatial structure
661            storey_elevation: None,
662        });
663    }
664
665    Ok((meshes, entities))
666}
667
668/// Check if an IFC type name (string) can have geometry representation
669/// This is used for fast scanning without decoding entities
670fn has_geometry_type_name(type_name: &str) -> bool {
671    matches!(
672        type_name.to_uppercase().as_str(),
673        // Walls
674        "IFCWALL"
675            | "IFCWALLSTANDARDCASE"
676            | "IFCCURTAINWALL"
677            // Slabs and floors
678            | "IFCSLAB"
679            // Roofs
680            | "IFCROOF"
681            // Structural elements
682            | "IFCBEAM"
683            | "IFCCOLUMN"
684            | "IFCMEMBER"
685            | "IFCPLATE"
686            // Openings
687            | "IFCDOOR"
688            | "IFCWINDOW"
689            // Circulation
690            | "IFCSTAIR"
691            | "IFCSTAIRFLIGHT"
692            | "IFCRAMP"
693            | "IFCRAMPFLIGHT"
694            | "IFCRAILING"
695            // Coverings
696            | "IFCCOVERING"
697            // Furniture
698            | "IFCFURNISHINGELEMENT"
699            // Foundations
700            | "IFCFOOTING"
701            | "IFCPILE"
702            // Generic building elements
703            | "IFCBUILDINGELEMENTPROXY"
704            | "IFCELEMENTASSEMBLY"
705            // MEP
706            | "IFCFLOWTERMINAL"
707            | "IFCFLOWSEGMENT"
708            | "IFCFLOWFITTING"
709            | "IFCFLOWCONTROLLER"
710            // Lighting
711            | "IFCLIGHTFIXTURE"
712            // Spaces (optional, often transparent)
713            | "IFCSPACE"
714    )
715}
716
717/// Check if an IFC type is a point-placed entity (no geometry, just a placement)
718fn is_point_entity_type(type_name: &str) -> bool {
719    matches!(type_name.to_uppercase().as_str(), "IFCLIGHTFIXTURE")
720}
721
722/// Extract world position from an entity's ObjectPlacement chain.
723///
724/// Uses `resolve_placement()` to follow the full PlacementRelTo chain,
725/// producing correct world coordinates even for nested fixtures.
726fn extract_entity_position(
727    entity: &bimifc_model::DecodedEntity,
728    resolver: &dyn bimifc_model::EntityResolver,
729    unit_scale: f64,
730) -> Option<[f32; 3]> {
731    let placement_id = entity.get_ref(5)?; // ObjectPlacement
732    let transform = bimifc_geometry::transform::resolve_placement(placement_id, resolver)?;
733    Some([
734        (transform[(0, 3)] * unit_scale) as f32,
735        (transform[(1, 3)] * unit_scale) as f32,
736        (transform[(2, 3)] * unit_scale) as f32,
737    ])
738}
739
740/// Create a UV sphere mesh at the given position.
741///
742/// Generates a small sphere (~96 triangles) as a visual marker
743/// for point-placed entities without their own geometry.
744fn create_marker_sphere(center: [f32; 3], radius: f32) -> bimifc_geometry::Mesh {
745    let stacks: u32 = 8;
746    let slices: u32 = 12;
747
748    let vertex_count = ((stacks + 1) * (slices + 1)) as usize;
749    let index_count = (stacks * slices * 6) as usize;
750
751    let mut positions = Vec::with_capacity(vertex_count * 3);
752    let mut normals = Vec::with_capacity(vertex_count * 3);
753    let mut indices = Vec::with_capacity(index_count);
754
755    // Generate vertices
756    for i in 0..=stacks {
757        let phi = std::f32::consts::PI * i as f32 / stacks as f32;
758        let sin_phi = phi.sin();
759        let cos_phi = phi.cos();
760
761        for j in 0..=slices {
762            let theta = 2.0 * std::f32::consts::PI * j as f32 / slices as f32;
763            let sin_theta = theta.sin();
764            let cos_theta = theta.cos();
765
766            let nx = sin_phi * cos_theta;
767            let ny = sin_phi * sin_theta;
768            let nz = cos_phi;
769
770            positions.push(center[0] + radius * nx);
771            positions.push(center[1] + radius * ny);
772            positions.push(center[2] + radius * nz);
773
774            normals.push(nx);
775            normals.push(ny);
776            normals.push(nz);
777        }
778    }
779
780    // Generate indices
781    for i in 0..stacks {
782        for j in 0..slices {
783            let row_start = i * (slices + 1);
784            let next_row = (i + 1) * (slices + 1);
785
786            let tl = row_start + j;
787            let tr = row_start + j + 1;
788            let bl = next_row + j;
789            let br = next_row + j + 1;
790
791            indices.push(tl);
792            indices.push(bl);
793            indices.push(tr);
794
795            indices.push(tr);
796            indices.push(bl);
797            indices.push(br);
798        }
799    }
800
801    bimifc_geometry::Mesh {
802        positions,
803        normals,
804        indices,
805    }
806}
807
808/// Build a map from geometry item entity ID → RGBA surface color.
809///
810/// Scans all IfcStyledItem entities and follows the chain:
811///   IfcStyledItem\[0\]=Item, \[1\]=Styles
812///   → IfcSurfaceStyle\[2\]=Styles
813///   → IfcSurfaceStyleRendering\[0\]=SurfaceColour
814///   → IfcColourRgb\[1\]=R, \[2\]=G, \[3\]=B
815fn build_styled_item_colors(resolver: &dyn EntityResolver) -> FxHashMap<u32, [f32; 4]> {
816    let mut color_map = FxHashMap::default();
817
818    for styled_item in resolver.entities_by_type(&IfcType::IfcStyledItem) {
819        // [0] Item — the geometry entity this style applies to
820        let item_id = match styled_item.get_ref(0) {
821            Some(id) => id,
822            None => continue,
823        };
824
825        // [1] Styles — list of style assignments
826        let styles = match styled_item.get(1) {
827            Some(AttributeValue::List(list)) => list,
828            _ => continue,
829        };
830
831        // Follow first style ref → IfcSurfaceStyle
832        let surface_style_id = match styles.first().and_then(|v| v.as_entity_ref()) {
833            Some(id) => id,
834            None => continue,
835        };
836        let surface_style = match resolver.get(surface_style_id) {
837            Some(e) => e,
838            None => continue,
839        };
840
841        // IfcSurfaceStyle[2] = Styles (list of rendering styles)
842        let sub_styles = match surface_style.get(2) {
843            Some(AttributeValue::List(list)) => list,
844            _ => continue,
845        };
846
847        let rendering_id = match sub_styles.first().and_then(|v| v.as_entity_ref()) {
848            Some(id) => id,
849            None => continue,
850        };
851        let rendering = match resolver.get(rendering_id) {
852            Some(e) => e,
853            None => continue,
854        };
855
856        // IfcSurfaceStyleRendering[0] = SurfaceColour → IfcColourRgb
857        let colour_id = match rendering.get_ref(0) {
858            Some(id) => id,
859            None => continue,
860        };
861        let colour = match resolver.get(colour_id) {
862            Some(e) => e,
863            None => continue,
864        };
865
866        // IfcColourRgb: [1]=Red, [2]=Green, [3]=Blue
867        let r = colour.get_float(1).unwrap_or(0.7) as f32;
868        let g = colour.get_float(2).unwrap_or(0.7) as f32;
869        let b = colour.get_float(3).unwrap_or(0.7) as f32;
870
871        color_map.insert(item_id.0, [r, g, b, 1.0]);
872    }
873
874    color_map
875}
876
877/// Get the IFC surface style color for a product entity, if any.
878///
879/// Follows: entity\[6\]=Representation → IfcProductDefinitionShape\[2\]=Representations
880/// → IfcShapeRepresentation\[3\]=Items → check each item against styled_item_colors map.
881fn get_entity_surface_color(
882    entity: &bimifc_model::DecodedEntity,
883    resolver: &dyn EntityResolver,
884    styled_colors: &FxHashMap<u32, [f32; 4]>,
885) -> Option<[f32; 4]> {
886    // Representation is at attribute index 6 for most IFC products
887    let rep_id = entity.get_ref(6)?;
888    let representation = resolver.get(rep_id)?;
889
890    // IfcProductDefinitionShape[2] = Representations (list)
891    let reps = match representation.get(2) {
892        Some(AttributeValue::List(list)) => list,
893        _ => return None,
894    };
895
896    for rep_ref in reps {
897        let shape_rep_id = rep_ref.as_entity_ref()?;
898        let shape_rep = resolver.get(shape_rep_id)?;
899
900        // IfcShapeRepresentation[3] = Items (list of geometry items)
901        let items = match shape_rep.get(3) {
902            Some(AttributeValue::List(list)) => list,
903            _ => continue,
904        };
905
906        for item_ref in items {
907            if let Some(item_id) = item_ref.as_entity_ref() {
908                if let Some(color) = styled_colors.get(&item_id.0) {
909                    return Some(*color);
910                }
911            }
912        }
913    }
914
915    None
916}