Skip to main content

bimifc_bevy/
lib.rs

1//! BIMIFC Bevy 3D Viewer
2//!
3//! Bevy-based 3D viewer for IFC models with WebGPU/WebGL2 rendering.
4//! Supports orbit/pan/zoom camera controls, entity selection, and section planes.
5//!
6//! Features pure Bevy UI that works on both web (WASM) and native platforms.
7
8// Allow unexpected_cfgs from objc crate's msg_send! macro used in native_view
9#![allow(unexpected_cfgs)]
10
11pub mod camera;
12pub mod loader;
13pub mod mesh;
14#[cfg(feature = "photometric")]
15pub mod photometric;
16pub mod picking;
17pub mod section;
18pub mod storage;
19
20#[cfg(feature = "bevy-ui")]
21pub mod ui;
22
23#[cfg(any(target_os = "ios", target_os = "macos"))]
24pub mod native_view;
25
26#[cfg(any(target_os = "ios", target_os = "macos"))]
27pub mod ffi;
28
29use bevy::prelude::*;
30use rustc_hash::FxHashSet;
31use serde::{Deserialize, Serialize};
32use std::sync::atomic::{AtomicBool, Ordering};
33use std::sync::Mutex;
34
35/// Global debug mode flag (set from URL parameter ?debug=1)
36static DEBUG_MODE: AtomicBool = AtomicBool::new(false);
37
38/// Pending meshes for unified mode (Yew -> Bevy direct transfer)
39/// This avoids serialization overhead when running in same WASM
40static PENDING_MESHES: Mutex<Option<Vec<IfcMesh>>> = Mutex::new(None);
41
42/// Set pending meshes from Yew (unified mode only)
43/// This is called by Yew after parsing geometry, Bevy polls this
44pub fn set_pending_meshes(meshes: Vec<IfcMesh>) {
45    let count = meshes.len();
46    let mut guard = PENDING_MESHES.lock().unwrap();
47    *guard = Some(meshes);
48    log(&format!("[Bevy] Pending meshes set: {} meshes", count));
49}
50
51/// Take pending meshes (consumes them)
52pub fn take_pending_meshes() -> Option<Vec<IfcMesh>> {
53    let mut guard = PENDING_MESHES.lock().unwrap();
54    guard.take()
55}
56
57/// Check if pending meshes are available
58pub fn has_pending_meshes() -> bool {
59    let guard = PENDING_MESHES.lock().unwrap();
60    guard.is_some()
61}
62
63/// Check if debug mode is enabled
64pub fn is_debug() -> bool {
65    DEBUG_MODE.load(Ordering::Relaxed)
66}
67
68/// Initialize debug mode from URL parameters
69#[cfg(target_arch = "wasm32")]
70fn init_debug_from_url() {
71    if let Some(window) = web_sys::window() {
72        if let Ok(search) = window.location().search() {
73            let search_str: &str = &search;
74            if search_str.contains("debug=1") || search_str.contains("debug=true") {
75                DEBUG_MODE.store(true, Ordering::Relaxed);
76                web_sys::console::log_1(&"[Bevy] Debug mode enabled".into());
77            }
78        }
79    }
80}
81
82#[cfg(not(target_arch = "wasm32"))]
83#[allow(dead_code)]
84fn init_debug_from_url() {
85    // Native: check env var
86    if std::env::var("DEBUG").is_ok() {
87        DEBUG_MODE.store(true, Ordering::Relaxed);
88    }
89}
90
91// Re-exports
92pub use camera::{CameraController, CameraMode, CameraPlugin};
93pub use loader::{LoadIfcContentEvent, LoadIfcFileEvent, LoaderPlugin, OpenFileDialogRequest};
94pub use mesh::{AutoFitState, IfcEntity, IfcMesh, IfcMeshSerialized, MeshGeometry, MeshPlugin};
95pub use picking::{PickingPlugin, SelectionState};
96pub use section::{SectionPlane, SectionPlanePlugin};
97pub use storage::*;
98
99#[cfg(feature = "bevy-ui")]
100pub use ui::{IfcUiPlugin, UiState};
101
102#[cfg(any(target_os = "ios", target_os = "macos"))]
103pub use native_view::{AppView, AppViewPlugin, AppViews};
104
105/// Main IFC viewer plugin - combines all subsystems
106pub struct IfcViewerPlugin;
107
108impl Plugin for IfcViewerPlugin {
109    fn build(&self, app: &mut App) {
110        app.init_resource::<IfcSceneData>()
111            .init_resource::<ViewerSettings>()
112            .init_resource::<IfcTimestamp>()
113            .add_plugins((
114                CameraPlugin,
115                MeshPlugin,
116                PickingPlugin,
117                SectionPlanePlugin,
118                LoaderPlugin,
119            ))
120            .add_systems(Update, (poll_scene_changes, poll_selection_from_storage));
121
122        // Add Bevy UI when feature is enabled
123        #[cfg(feature = "bevy-ui")]
124        app.add_plugins(IfcUiPlugin);
125
126        // Add photometric lighting when feature is enabled
127        #[cfg(feature = "photometric")]
128        app.add_plugins(photometric::PhotometricLightingPlugin);
129    }
130}
131
132/// Resource containing all IFC scene data
133#[derive(Resource, Default)]
134pub struct IfcSceneData {
135    /// All meshes in the scene
136    pub meshes: Vec<IfcMesh>,
137    /// Entity metadata (type, name, properties)
138    pub entities: Vec<EntityInfo>,
139    /// Scene bounds (AABB)
140    pub bounds: Option<SceneBounds>,
141    /// Data timestamp for change detection
142    pub timestamp: u64,
143    /// Whether scene needs rebuild
144    pub dirty: bool,
145}
146
147/// Entity metadata
148#[derive(Clone, Debug, Serialize, Deserialize)]
149pub struct EntityInfo {
150    pub id: u64,
151    pub entity_type: String,
152    pub name: Option<String>,
153    pub storey: Option<String>,
154    pub storey_elevation: Option<f32>,
155}
156
157/// Axis-aligned bounding box for scene
158#[derive(Clone, Debug, Default)]
159pub struct SceneBounds {
160    pub min: Vec3,
161    pub max: Vec3,
162}
163
164impl SceneBounds {
165    pub fn center(&self) -> Vec3 {
166        (self.min + self.max) * 0.5
167    }
168
169    pub fn size(&self) -> Vec3 {
170        self.max - self.min
171    }
172
173    pub fn diagonal(&self) -> f32 {
174        self.size().length()
175    }
176}
177
178/// Viewer settings and state
179#[derive(Resource)]
180pub struct ViewerSettings {
181    /// Current theme (affects background color)
182    pub theme: Theme,
183    /// Show grid
184    pub show_grid: bool,
185    /// Show axes helper
186    pub show_axes: bool,
187    /// Hidden entity IDs
188    pub hidden_entities: FxHashSet<u64>,
189    /// Isolated entity IDs (if Some, only show these)
190    pub isolated_entities: Option<FxHashSet<u64>>,
191    /// Active storey filter
192    pub storey_filter: Option<String>,
193}
194
195impl Default for ViewerSettings {
196    fn default() -> Self {
197        Self {
198            theme: Theme::Dark,
199            show_grid: true,
200            show_axes: true,
201            hidden_entities: FxHashSet::default(),
202            isolated_entities: None,
203            storey_filter: None,
204        }
205    }
206}
207
208/// Theme variants
209#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
210pub enum Theme {
211    Light,
212    #[default]
213    Dark,
214}
215
216impl Theme {
217    pub fn background_color(&self) -> Color {
218        match self {
219            Theme::Light => Color::srgb(0.95, 0.95, 0.95),
220            Theme::Dark => Color::srgb(0.12, 0.12, 0.12),
221        }
222    }
223
224    pub fn grid_color(&self) -> Color {
225        match self {
226            Theme::Light => Color::srgba(0.5, 0.5, 0.5, 0.3),
227            Theme::Dark => Color::srgba(0.4, 0.4, 0.4, 0.3),
228        }
229    }
230}
231
232/// Timestamp for detecting localStorage changes (WASM)
233#[derive(Resource, Default)]
234pub struct IfcTimestamp(pub String);
235
236/// System to poll for scene changes
237/// Checks both direct memory (unified mode) and JS bridge (split mode)
238#[allow(unused_variables, unused_mut)]
239pub fn poll_scene_changes(
240    mut scene_data: ResMut<IfcSceneData>,
241    mut settings: ResMut<ViewerSettings>,
242    mut last_timestamp: ResMut<IfcTimestamp>,
243    mut auto_fit: ResMut<mesh::AutoFitState>,
244) {
245    // UNIFIED MODE: Check for direct memory transfer first (no serialization!)
246    if let Some(meshes) = take_pending_meshes() {
247        log_info(&format!(
248            "[Bevy] Direct mesh transfer: {} meshes (no deserialization!)",
249            meshes.len()
250        ));
251
252        // Build EntityInfo from meshes
253        scene_data.entities = meshes
254            .iter()
255            .map(|m| EntityInfo {
256                id: m.entity_id,
257                entity_type: m.entity_type.clone(),
258                name: m.name.clone(),
259                storey: None,
260                storey_elevation: None,
261            })
262            .collect();
263
264        scene_data.meshes = meshes;
265        scene_data.dirty = true;
266        auto_fit.has_fit = false;
267    } else {
268        // SPLIT MODE: Fall back to JS bridge polling
269        #[cfg(target_arch = "wasm32")]
270        {
271            if let Some(new_timestamp) = storage::get_timestamp() {
272                if new_timestamp != last_timestamp.0 {
273                    log(&format!(
274                        "[Bevy] Timestamp changed: {} -> {}",
275                        last_timestamp.0, new_timestamp
276                    ));
277
278                    // Load geometry from storage (binary deserialization)
279                    if let Some(geometry) = storage::load_geometry() {
280                        log(&format!(
281                            "[Bevy] Loaded {} meshes from JS bridge",
282                            geometry.len()
283                        ));
284
285                        // Build EntityInfo directly from meshes
286                        scene_data.entities = geometry
287                            .iter()
288                            .map(|m| EntityInfo {
289                                id: m.entity_id,
290                                entity_type: m.entity_type.clone(),
291                                name: m.name.clone(),
292                                storey: None,
293                                storey_elevation: None,
294                            })
295                            .collect();
296
297                        scene_data.meshes = geometry;
298                        scene_data.dirty = true;
299                        auto_fit.has_fit = false;
300                    }
301
302                    // Load selection state
303                    if let Some(selection) = storage::load_selection() {
304                        // Selection is handled by PickingPlugin
305                    }
306
307                    // Load visibility state
308                    if let Some(visibility) = storage::load_visibility() {
309                        settings.hidden_entities = visibility.hidden.into_iter().collect();
310                        settings.isolated_entities =
311                            visibility.isolated.map(|v| v.into_iter().collect());
312                    }
313
314                    last_timestamp.0 = new_timestamp;
315                }
316            }
317        }
318    }
319}
320
321/// System to poll selection changes from localStorage (UI -> Bevy sync)
322/// This allows the UI (Leptos/Yew) to update Bevy's selection via localStorage
323#[allow(unused_variables, unused_mut)]
324pub fn poll_selection_from_storage(mut selection: ResMut<picking::SelectionState>) {
325    #[cfg(target_arch = "wasm32")]
326    {
327        // Check if there's a pending selection from UI
328        if let Some(stored_selection) = storage::load_selection() {
329            // Only update if source is "leptos" or "yew" (UI-initiated)
330            // Skip if source is "bevy" to prevent loops
331            if let Some(source) = storage::get_selection_source() {
332                if source == "bevy" {
333                    return;
334                }
335            }
336
337            // Convert to HashSet for comparison
338            let new_selection: FxHashSet<u64> = stored_selection.selected_ids.into_iter().collect();
339
340            // Only update if actually different
341            if selection.selected != new_selection {
342                selection.selected = new_selection;
343                // Mark as changed so mesh selection system updates colors
344            }
345        }
346    }
347}
348
349/// Log to browser console (WASM) or stdout (native) - only in debug mode
350#[cfg(target_arch = "wasm32")]
351pub fn log(msg: &str) {
352    if is_debug() {
353        web_sys::console::log_1(&msg.into());
354    }
355}
356
357#[cfg(not(target_arch = "wasm32"))]
358pub fn log(msg: &str) {
359    if is_debug() {
360        println!("{}", msg);
361    }
362}
363
364/// Log info that should always be shown
365#[cfg(target_arch = "wasm32")]
366pub fn log_info(msg: &str) {
367    web_sys::console::info_1(&msg.into());
368}
369
370#[cfg(not(target_arch = "wasm32"))]
371pub fn log_info(msg: &str) {
372    println!("{}", msg);
373}
374
375/// Run the viewer on a canvas element (WASM)
376///
377/// This is the unified single-WASM viewer. It starts with an empty scene
378/// and the user can load IFC files using:
379/// - The "Open" button in the toolbar (Bevy UI mode)
380/// - Drag and drop onto the canvas
381#[cfg(target_arch = "wasm32")]
382#[wasm_bindgen::prelude::wasm_bindgen]
383pub fn run_on_canvas(canvas_selector: &str) {
384    console_error_panic_hook::set_once();
385    init_debug_from_url();
386    log_info(&format!(
387        "[Bevy] Starting unified viewer on canvas: {}",
388        canvas_selector
389    ));
390
391    // Start with empty scene - user will load files via UI or drag-and-drop
392    let scene_data = IfcSceneData::default();
393
394    let mut app = App::new();
395
396    // Insert resources before plugins
397    app.insert_resource(scene_data);
398    app.insert_resource(ViewerSettings::default());
399    app.insert_resource(IfcTimestamp::default());
400
401    // Add plugins
402    app.add_plugins(DefaultPlugins.set(WindowPlugin {
403        primary_window: Some(Window {
404            title: "BIMIFC Viewer".to_string(),
405            canvas: Some(canvas_selector.to_string()),
406            fit_canvas_to_parent: true,
407            prevent_default_event_handling: false,
408            ..default()
409        }),
410        ..default()
411    }));
412
413    app.add_plugins(IfcViewerPlugin);
414    app.run();
415}
416
417/// Run the viewer in a native window (desktop)
418#[cfg(not(target_arch = "wasm32"))]
419pub fn run_on_canvas(_canvas_selector: &str) {
420    run_native();
421}
422
423/// Run native desktop viewer
424#[cfg(not(target_arch = "wasm32"))]
425pub fn run_native() {
426    App::new()
427        .add_plugins(DefaultPlugins.set(WindowPlugin {
428            primary_window: Some(Window {
429                title: "BIMIFC Viewer".to_string(),
430                resolution: (1280u32, 720u32).into(),
431                ..default()
432            }),
433            ..default()
434        }))
435        // Dark gray background so we can see if rendering works
436        .insert_resource(ClearColor(Color::srgb(0.1, 0.1, 0.15)))
437        .add_plugins(IfcViewerPlugin)
438        .run();
439}
440
441#[cfg(target_arch = "wasm32")]
442pub fn run_native() {
443    run_on_canvas("#bevy-canvas");
444}
445
446/// WASM entry point
447#[cfg(target_arch = "wasm32")]
448#[wasm_bindgen::prelude::wasm_bindgen]
449pub fn wasm_start() {
450    log("[Bevy] wasm_start called");
451    run_native();
452}
453
454/// Run Bevy with pre-loaded scene data (for unified Yew+Bevy mode)
455/// This allows Yew to parse IFC and pass data directly without JS bridge
456#[cfg(target_arch = "wasm32")]
457pub fn run_with_data(canvas_selector: &str, scene_data: IfcSceneData) {
458    console_error_panic_hook::set_once();
459    init_debug_from_url();
460    log_info(&format!(
461        "[Bevy] Starting with data: {} meshes, {} entities",
462        scene_data.meshes.len(),
463        scene_data.entities.len()
464    ));
465
466    let mut app = App::new();
467
468    // Insert scene data directly - no localStorage polling needed
469    app.insert_resource(scene_data);
470    app.insert_resource(ViewerSettings::default());
471    app.insert_resource(IfcTimestamp::default());
472
473    // Add plugins
474    app.add_plugins(DefaultPlugins.set(WindowPlugin {
475        primary_window: Some(Window {
476            title: "BIMIFC Viewer".to_string(),
477            canvas: Some(canvas_selector.to_string()),
478            fit_canvas_to_parent: true,
479            prevent_default_event_handling: false,
480            ..default()
481        }),
482        ..default()
483    }));
484
485    app.add_plugins(IfcViewerPlugin);
486    app.run();
487}
488
489#[cfg(not(target_arch = "wasm32"))]
490pub fn run_with_data(_canvas_selector: &str, _scene_data: IfcSceneData) {
491    run_native();
492}