Skip to main content

bimifc_bevy/
mesh.rs

1//! Mesh system for IFC geometry
2//!
3//! Handles loading IFC geometry into Bevy meshes with materials.
4//!
5//! ## Performance: Batched Rendering
6//!
7//! Instead of creating one Bevy entity per IFC entity (which causes 1000+ draw calls),
8//! we batch meshes by material type into a few large meshes:
9//! - Opaque batch: All solid geometry in one draw call
10//! - Transparent batch: All glass/windows in one draw call
11//!
12//! This reduces draw calls from N to 2-3, dramatically improving orbit/pan performance.
13//!
14//! ## Memory Optimization: Arc-based Geometry Sharing
15//!
16//! Geometry data (positions, normals, indices) is stored in `Arc<MeshGeometry>` to avoid
17//! expensive cloning. This saves ~1.7GB RAM on a 200MB IFC file by sharing geometry
18//! between the parser output and our mesh structures.
19
20use crate::{log, IfcSceneData, SceneBounds};
21use bevy::asset::RenderAssetUsages;
22#[cfg(feature = "color-palette")]
23use bevy::mesh::VertexAttributeValues;
24use bevy::mesh::{Indices, PrimitiveTopology};
25use bevy::prelude::*;
26use serde::{Deserialize, Serialize};
27use std::sync::Arc;
28
29/// Mesh plugin
30pub struct MeshPlugin;
31
32impl Plugin for MeshPlugin {
33    fn build(&self, app: &mut App) {
34        app.init_resource::<AutoFitState>()
35            .init_resource::<PendingFocus>()
36            .init_resource::<TriangleEntityMapping>()
37            .init_resource::<CurrentPalette>()
38            .init_resource::<EntityColorMapping>()
39            .init_resource::<PreviousSelection>()
40            .init_resource::<PreviousVisibility>()
41            .add_systems(
42                Update,
43                (
44                    poll_palette_change_system,
45                    spawn_meshes_system,
46                    auto_fit_camera_system,
47                    update_mesh_visibility_system,
48                    update_mesh_selection_system,
49                    poll_focus_command_system,
50                )
51                    .chain(),
52            );
53    }
54}
55
56/// Resource for pending focus command
57#[derive(Resource, Default)]
58pub struct PendingFocus {
59    pub entity_id: Option<u64>,
60}
61
62/// State for auto-fit camera on first load
63#[derive(Resource, Default)]
64pub struct AutoFitState {
65    /// Whether we've already auto-fit for this scene
66    pub has_fit: bool,
67}
68
69/// Current color palette state
70#[cfg(feature = "color-palette")]
71#[derive(Resource, Default)]
72pub struct CurrentPalette {
73    pub palette: ColorPalette,
74}
75
76#[cfg(not(feature = "color-palette"))]
77#[derive(Resource, Default)]
78pub struct CurrentPalette;
79
80/// Lightweight entity info for palette switching and selection highlighting (no geometry data)
81#[cfg(feature = "color-palette")]
82#[derive(Clone, Debug)]
83pub struct EntityColorInfo {
84    pub entity_id: u64,
85    pub entity_type: String,
86    pub original_color: [f32; 4],
87    /// True if color comes from IFC IfcStyledItem (should be preserved across palette changes)
88    pub has_ifc_color: bool,
89    pub start_vertex: u32,
90    pub vertex_count: u32,
91}
92
93/// Maps entity color info for palette switching without keeping geometry
94#[cfg(feature = "color-palette")]
95#[derive(Resource, Default)]
96pub struct EntityColorMapping {
97    /// Opaque mesh entity mappings
98    pub opaque: Vec<EntityColorInfo>,
99    /// Transparent mesh entity mappings
100    pub transparent: Vec<EntityColorInfo>,
101}
102
103#[cfg(not(feature = "color-palette"))]
104#[derive(Resource, Default)]
105pub struct EntityColorMapping;
106
107/// Shared geometry data - uses Arc to avoid expensive cloning
108///
109/// This struct holds the heavy data (positions, normals, indices) that would
110/// otherwise be cloned multiple times through the pipeline. Using Arc saves
111/// ~1.7GB RAM on a 200MB IFC file.
112#[derive(Clone, Debug, Default)]
113pub struct MeshGeometry {
114    /// Vertex positions (flattened: [x0,y0,z0, x1,y1,z1, ...])
115    pub positions: Vec<f32>,
116    /// Vertex normals (flattened: [nx0,ny0,nz0, ...])
117    pub normals: Vec<f32>,
118    /// Triangle indices
119    pub indices: Vec<u32>,
120}
121
122impl MeshGeometry {
123    /// Create new geometry from vectors (takes ownership, no clone)
124    pub fn new(positions: Vec<f32>, normals: Vec<f32>, indices: Vec<u32>) -> Self {
125        Self {
126            positions,
127            normals,
128            indices,
129        }
130    }
131
132    /// Create from bimifc_geometry::Mesh (takes ownership via conversion)
133    pub fn from_geometry_mesh(mesh: bimifc_geometry::Mesh) -> Self {
134        Self {
135            positions: mesh.positions,
136            normals: mesh.normals,
137            indices: mesh.indices,
138        }
139    }
140
141    /// Vertex count
142    pub fn vertex_count(&self) -> usize {
143        self.positions.len() / 3
144    }
145
146    /// Triangle count
147    pub fn triangle_count(&self) -> usize {
148        self.indices.len() / 3
149    }
150
151    /// Check if empty
152    pub fn is_empty(&self) -> bool {
153        self.positions.is_empty()
154    }
155}
156
157/// IFC mesh data with Arc-based geometry sharing
158///
159/// The geometry data is wrapped in Arc to enable zero-copy sharing between
160/// the parser/geometry processor and the Bevy mesh system. Only the lightweight
161/// metadata (color, transform, entity info) is owned per-instance.
162#[derive(Clone, Debug)]
163pub struct IfcMesh {
164    /// Entity ID
165    pub entity_id: u64,
166    /// Shared geometry data (positions, normals, indices)
167    pub geometry: Arc<MeshGeometry>,
168    /// Base color [r, g, b, a]
169    pub color: [f32; 4],
170    /// Transform matrix (column-major 4x4)
171    pub transform: [f32; 16],
172    /// Entity type (e.g., "IfcWall")
173    pub entity_type: String,
174    /// Entity name
175    pub name: Option<String>,
176    /// True if color was authored in IFC (IfcStyledItem), should survive palette changes
177    pub has_ifc_color: bool,
178}
179
180/// Legacy serializable format for storage/transfer
181/// Used for web storage where we need JSON serialization
182#[derive(Clone, Debug, Serialize, Deserialize)]
183pub struct IfcMeshSerialized {
184    /// Entity ID
185    pub entity_id: u64,
186    /// Vertex positions (flattened: [x0,y0,z0, x1,y1,z1, ...])
187    pub positions: Vec<f32>,
188    /// Vertex normals (flattened: [nx0,ny0,nz0, ...])
189    pub normals: Vec<f32>,
190    /// Triangle indices
191    pub indices: Vec<u32>,
192    /// Base color [r, g, b, a]
193    pub color: [f32; 4],
194    /// Transform matrix (column-major 4x4)
195    pub transform: [f32; 16],
196    /// Entity type (e.g., "IfcWall")
197    pub entity_type: String,
198    /// Entity name
199    pub name: Option<String>,
200}
201
202impl From<IfcMeshSerialized> for IfcMesh {
203    fn from(s: IfcMeshSerialized) -> Self {
204        Self {
205            entity_id: s.entity_id,
206            geometry: Arc::new(MeshGeometry::new(s.positions, s.normals, s.indices)),
207            color: s.color,
208            transform: s.transform,
209            entity_type: s.entity_type,
210            name: s.name,
211            has_ifc_color: false,
212        }
213    }
214}
215
216impl From<&IfcMesh> for IfcMeshSerialized {
217    fn from(m: &IfcMesh) -> Self {
218        Self {
219            entity_id: m.entity_id,
220            positions: m.geometry.positions.clone(),
221            normals: m.geometry.normals.clone(),
222            indices: m.geometry.indices.clone(),
223            color: m.color,
224            transform: m.transform,
225            entity_type: m.entity_type.clone(),
226            name: m.name.clone(),
227        }
228    }
229}
230
231impl IfcMesh {
232    /// Create a new IfcMesh with Arc-wrapped geometry (no cloning)
233    pub fn new(
234        entity_id: u64,
235        geometry: Arc<MeshGeometry>,
236        color: [f32; 4],
237        transform: [f32; 16],
238        entity_type: String,
239        name: Option<String>,
240    ) -> Self {
241        Self {
242            entity_id,
243            geometry,
244            color,
245            transform,
246            entity_type,
247            name,
248            has_ifc_color: false,
249        }
250    }
251
252    /// Create from geometry mesh, taking ownership (no clone)
253    pub fn from_geometry_mesh(
254        entity_id: u64,
255        mesh: bimifc_geometry::Mesh,
256        color: [f32; 4],
257        entity_type: String,
258        name: Option<String>,
259    ) -> Self {
260        Self {
261            entity_id,
262            geometry: Arc::new(MeshGeometry::from_geometry_mesh(mesh)),
263            color,
264            transform: [
265                1.0, 0.0, 0.0, 0.0, // column 0
266                0.0, 1.0, 0.0, 0.0, // column 1
267                0.0, 0.0, 1.0, 0.0, // column 2
268                0.0, 0.0, 0.0, 1.0, // column 3
269            ],
270            entity_type,
271            name,
272            has_ifc_color: false,
273        }
274    }
275
276    /// Check if geometry is empty
277    pub fn is_empty(&self) -> bool {
278        self.geometry.is_empty()
279    }
280
281    /// Convert to Bevy mesh
282    pub fn to_bevy_mesh(&self) -> Mesh {
283        let vertex_count = self.geometry.vertex_count();
284
285        // Parse positions
286        let positions: Vec<[f32; 3]> = (0..vertex_count)
287            .map(|i| {
288                let idx = i * 3;
289                // Convert from IFC Z-up to Bevy Y-up
290                [
291                    self.geometry.positions[idx],      // X stays
292                    self.geometry.positions[idx + 2],  // Z -> Y
293                    -self.geometry.positions[idx + 1], // -Y -> Z
294                ]
295            })
296            .collect();
297
298        // Parse normals (with same coordinate conversion)
299        let normals: Vec<[f32; 3]> = if self.geometry.normals.len() == self.geometry.positions.len()
300        {
301            (0..vertex_count)
302                .map(|i| {
303                    let idx = i * 3;
304                    [
305                        self.geometry.normals[idx],
306                        self.geometry.normals[idx + 2],
307                        -self.geometry.normals[idx + 1],
308                    ]
309                })
310                .collect()
311        } else {
312            // Compute flat normals from triangles if not provided
313            compute_flat_normals(&positions, &self.geometry.indices)
314        };
315
316        let mut mesh = Mesh::new(
317            PrimitiveTopology::TriangleList,
318            RenderAssetUsages::default(),
319        );
320
321        mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
322        mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
323        mesh.insert_indices(Indices::U32(self.geometry.indices.clone()));
324
325        mesh
326    }
327
328    /// Get transform as Bevy Transform
329    pub fn get_transform(&self) -> Transform {
330        let mat = Mat4::from_cols_array(&self.transform);
331        Transform::from_matrix(mat)
332    }
333
334    /// Get color as Bevy Color
335    pub fn get_color(&self) -> Color {
336        Color::srgba(self.color[0], self.color[1], self.color[2], self.color[3])
337    }
338}
339
340/// Marker component for IFC entities
341#[derive(Component)]
342pub struct IfcEntity {
343    pub id: u64,
344    pub entity_type: String,
345    pub name: Option<String>,
346}
347
348/// Entity bounding box component (for zoom-to-entity)
349#[derive(Component, Clone, Debug)]
350pub struct EntityBounds {
351    pub min: Vec3,
352    pub max: Vec3,
353}
354
355impl EntityBounds {
356    pub fn center(&self) -> Vec3 {
357        (self.min + self.max) * 0.5
358    }
359
360    pub fn diagonal(&self) -> f32 {
361        (self.max - self.min).length()
362    }
363}
364
365/// Marker for entities that need material update
366#[derive(Component)]
367pub struct NeedsMaterialUpdate;
368
369/// Marker for batched mesh entities
370#[derive(Component)]
371pub struct BatchedMesh {
372    /// Whether this batch is transparent
373    pub is_transparent: bool,
374}
375
376/// Resource mapping triangle indices to entity IDs for picking
377#[derive(Resource, Default)]
378pub struct TriangleEntityMapping {
379    /// Maps triangle index -> entity ID for opaque batch
380    pub opaque: Vec<u64>,
381    /// Maps triangle index -> entity ID for transparent batch
382    pub transparent: Vec<u64>,
383}
384
385impl TriangleEntityMapping {
386    /// Look up entity ID from triangle index
387    pub fn get_entity(&self, is_transparent: bool, triangle_index: usize) -> Option<u64> {
388        let mapping = if is_transparent {
389            &self.transparent
390        } else {
391            &self.opaque
392        };
393        mapping.get(triangle_index).copied()
394    }
395}
396
397/// Batched geometry builder - combines multiple meshes into one
398struct BatchBuilder {
399    positions: Vec<[f32; 3]>,
400    normals: Vec<[f32; 3]>,
401    colors: Vec<[f32; 4]>,
402    indices: Vec<u32>,
403    /// Maps triangle index -> entity_id (for picking)
404    triangle_to_entity: Vec<u64>,
405    /// Lightweight entity info for palette switching (only when feature enabled)
406    #[cfg(feature = "color-palette")]
407    entity_color_info: Vec<EntityColorInfo>,
408}
409
410impl BatchBuilder {
411    fn with_capacity(vertex_hint: usize, index_hint: usize) -> Self {
412        Self {
413            positions: Vec::with_capacity(vertex_hint),
414            normals: Vec::with_capacity(vertex_hint),
415            colors: Vec::with_capacity(vertex_hint),
416            indices: Vec::with_capacity(index_hint),
417            triangle_to_entity: Vec::with_capacity(index_hint / 3),
418            #[cfg(feature = "color-palette")]
419            entity_color_info: Vec::new(),
420        }
421    }
422
423    /// Add a mesh to the batch, transforming vertices to world space
424    fn add_mesh(&mut self, ifc_mesh: &IfcMesh) {
425        let geometry = &ifc_mesh.geometry;
426        let vertex_count = geometry.vertex_count();
427        if vertex_count == 0 {
428            return;
429        }
430
431        let start_vertex = self.positions.len();
432        let transform = ifc_mesh.get_transform();
433        let color = [
434            ifc_mesh.color[0],
435            ifc_mesh.color[1],
436            ifc_mesh.color[2],
437            ifc_mesh.color[3],
438        ];
439
440        // Transform positions to world space and convert Z-up to Y-up
441        for i in 0..vertex_count {
442            let idx = i * 3;
443            // Convert from IFC Z-up to Bevy Y-up
444            let local_pos = Vec3::new(
445                geometry.positions[idx],
446                geometry.positions[idx + 2],  // Z -> Y
447                -geometry.positions[idx + 1], // -Y -> Z
448            );
449            let world_pos = transform.transform_point(local_pos);
450            self.positions.push([world_pos.x, world_pos.y, world_pos.z]);
451
452            // Transform normals (rotation only, no translation)
453            if geometry.normals.len() == geometry.positions.len() {
454                let local_normal = Vec3::new(
455                    geometry.normals[idx],
456                    geometry.normals[idx + 2],
457                    -geometry.normals[idx + 1],
458                );
459                let world_normal = transform.rotation * local_normal;
460                self.normals
461                    .push([world_normal.x, world_normal.y, world_normal.z]);
462            } else {
463                self.normals.push([0.0, 1.0, 0.0]); // Default up
464            }
465
466            self.colors.push(color);
467        }
468
469        // Add indices with offset and track triangle-to-entity mapping
470        let index_offset = start_vertex as u32;
471        let num_triangles = geometry.triangle_count();
472        for &idx in &geometry.indices {
473            self.indices.push(idx + index_offset);
474        }
475
476        // Map each triangle to its entity ID (for picking)
477        for _ in 0..num_triangles {
478            self.triangle_to_entity.push(ifc_mesh.entity_id);
479        }
480
481        // Track lightweight entity info for palette switching and selection.
482        #[cfg(feature = "color-palette")]
483        self.entity_color_info.push(EntityColorInfo {
484            entity_id: ifc_mesh.entity_id,
485            entity_type: ifc_mesh.entity_type.clone(),
486            original_color: color,
487            has_ifc_color: ifc_mesh.has_ifc_color,
488            start_vertex: start_vertex as u32,
489            vertex_count: vertex_count as u32,
490        });
491    }
492
493    /// Get the triangle-to-entity mapping (consumes ownership)
494    fn take_triangle_mapping(&mut self) -> Vec<u64> {
495        std::mem::take(&mut self.triangle_to_entity)
496    }
497
498    /// Get the entity color info (consumes ownership)
499    #[cfg(feature = "color-palette")]
500    fn take_color_info(&mut self) -> Vec<EntityColorInfo> {
501        std::mem::take(&mut self.entity_color_info)
502    }
503
504    /// Build final Bevy mesh
505    fn build(self) -> Mesh {
506        let usage = RenderAssetUsages::default();
507        let mut mesh = Mesh::new(PrimitiveTopology::TriangleList, usage);
508
509        // Recompute normals if we didn't have proper ones
510        let normals = if self.normals.iter().all(|n| n[1] == 1.0 && n[0] == 0.0) {
511            compute_flat_normals(&self.positions, &self.indices)
512        } else {
513            self.normals
514        };
515
516        mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, self.positions);
517        mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
518        mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, self.colors);
519        mesh.insert_indices(Indices::U32(self.indices));
520
521        mesh
522    }
523
524    fn is_empty(&self) -> bool {
525        self.positions.is_empty()
526    }
527
528    fn vertex_count(&self) -> usize {
529        self.positions.len()
530    }
531
532    fn triangle_count(&self) -> usize {
533        self.indices.len() / 3
534    }
535}
536
537/// Get current time in milliseconds (WASM)
538#[cfg(target_arch = "wasm32")]
539fn now_ms() -> f64 {
540    js_sys::Date::now()
541}
542
543#[cfg(not(target_arch = "wasm32"))]
544fn now_ms() -> f64 {
545    use std::time::{SystemTime, UNIX_EPOCH};
546    SystemTime::now()
547        .duration_since(UNIX_EPOCH)
548        .map(|d| d.as_millis() as f64)
549        .unwrap_or(0.0)
550}
551
552/// System to spawn batched meshes when scene data changes
553#[allow(clippy::too_many_arguments, unused_mut)]
554fn spawn_meshes_system(
555    mut commands: Commands,
556    mut meshes: ResMut<Assets<Mesh>>,
557    mut materials: ResMut<Assets<StandardMaterial>>,
558    mut scene_data: ResMut<IfcSceneData>,
559    mut triangle_mapping: ResMut<TriangleEntityMapping>,
560    mut color_mapping: ResMut<EntityColorMapping>,
561    existing_entities: Query<Entity, With<IfcEntity>>,
562    existing_batches: Query<Entity, With<BatchedMesh>>,
563) {
564    if !scene_data.dirty {
565        return;
566    }
567
568    let batch_start = now_ms();
569    let mesh_count = scene_data.meshes.len();
570    crate::log_info(&format!("[Bevy] Batching {} meshes for GPU...", mesh_count));
571
572    // Clear previous mappings
573    triangle_mapping.opaque.clear();
574    triangle_mapping.transparent.clear();
575    #[cfg(feature = "color-palette")]
576    {
577        color_mapping.opaque.clear();
578        color_mapping.transparent.clear();
579    }
580    let _ = &color_mapping; // Silence unused warning when feature disabled
581
582    // Despawn existing entities and batches
583    for entity in existing_entities.iter() {
584        commands.entity(entity).despawn();
585    }
586    for entity in existing_batches.iter() {
587        commands.entity(entity).despawn();
588    }
589
590    // Estimate capacity (rough: 100 verts per mesh average)
591    let vertex_hint = mesh_count * 100;
592    let index_hint = mesh_count * 300;
593
594    let mut opaque_batch = BatchBuilder::with_capacity(vertex_hint, index_hint);
595    let mut metallic_batch = BatchBuilder::with_capacity(vertex_hint / 5, index_hint / 5);
596    let mut transparent_batch = BatchBuilder::with_capacity(vertex_hint / 10, index_hint / 10);
597
598    // Track bounds
599    let mut scene_min = Vec3::splat(f32::INFINITY);
600    let mut scene_max = Vec3::splat(f32::NEG_INFINITY);
601
602    // Process all meshes - group by material type
603    for ifc_mesh in &scene_data.meshes {
604        let is_transparent = ifc_mesh.color[3] < 1.0;
605        let transform = ifc_mesh.get_transform();
606        let geometry = &ifc_mesh.geometry;
607
608        // Compute entity bounds
609        let mut entity_min = Vec3::splat(f32::INFINITY);
610        let mut entity_max = Vec3::splat(f32::NEG_INFINITY);
611        for i in (0..geometry.positions.len()).step_by(3) {
612            let pos = Vec3::new(
613                geometry.positions[i],
614                geometry.positions[i + 2],
615                -geometry.positions[i + 1],
616            );
617            let world_pos = transform.transform_point(pos);
618            entity_min = entity_min.min(world_pos);
619            entity_max = entity_max.max(world_pos);
620            scene_min = scene_min.min(world_pos);
621            scene_max = scene_max.max(world_pos);
622        }
623
624        // Determine material type from entity type
625        let etype = ifc_mesh.entity_type.to_uppercase();
626        let is_metallic = etype.contains("BEAM")
627            || etype.contains("COLUMN")
628            || etype.contains("RAILING")
629            || (etype.contains("ROOF") && !etype.contains("SLAB"));
630
631        // Add to appropriate batch
632        if is_transparent {
633            transparent_batch.add_mesh(ifc_mesh);
634        } else if is_metallic {
635            metallic_batch.add_mesh(ifc_mesh);
636        } else {
637            opaque_batch.add_mesh(ifc_mesh);
638        }
639
640        // Spawn lightweight entity for selection/visibility (no mesh, just metadata)
641        commands.spawn((
642            IfcEntity {
643                id: ifc_mesh.entity_id,
644                entity_type: ifc_mesh.entity_type.clone(),
645                name: ifc_mesh.name.clone(),
646            },
647            EntityBounds {
648                min: entity_min,
649                max: entity_max,
650            },
651            Transform::default(),
652            Visibility::default(),
653        ));
654    }
655
656    // Spawn opaque batch
657    if !opaque_batch.is_empty() {
658        log(&format!(
659            "[Bevy] Opaque batch: {} vertices, {} triangles",
660            opaque_batch.vertex_count(),
661            opaque_batch.triangle_count()
662        ));
663
664        // Store mappings for picking
665        triangle_mapping.opaque = opaque_batch.take_triangle_mapping();
666        #[cfg(feature = "color-palette")]
667        {
668            color_mapping.opaque = opaque_batch.take_color_info();
669        }
670
671        let mesh = opaque_batch.build();
672        let material = StandardMaterial {
673            base_color: Color::WHITE,
674            metallic: 0.05,
675            perceptual_roughness: 0.45,
676            reflectance: 0.4,
677            double_sided: true,
678            cull_mode: None,
679            ..default()
680        };
681
682        commands.spawn((
683            Mesh3d(meshes.add(mesh)),
684            MeshMaterial3d(materials.add(material)),
685            Transform::default(),
686            BatchedMesh {
687                is_transparent: false,
688            },
689        ));
690    }
691
692    // Spawn metallic batch (beams, columns, railings, roofs)
693    if !metallic_batch.is_empty() {
694        log(&format!(
695            "[Bevy] Metallic batch: {} vertices, {} triangles",
696            metallic_batch.vertex_count(),
697            metallic_batch.triangle_count()
698        ));
699
700        triangle_mapping
701            .opaque
702            .extend(metallic_batch.take_triangle_mapping());
703
704        let mesh = metallic_batch.build();
705        let material = StandardMaterial {
706            base_color: Color::WHITE,
707            metallic: 0.7,
708            perceptual_roughness: 0.25,
709            reflectance: 0.6,
710            double_sided: true,
711            cull_mode: None,
712            ..default()
713        };
714
715        commands.spawn((
716            Mesh3d(meshes.add(mesh)),
717            MeshMaterial3d(materials.add(material)),
718            Transform::default(),
719            BatchedMesh {
720                is_transparent: false,
721            },
722        ));
723    }
724
725    // Spawn transparent batch
726    if !transparent_batch.is_empty() {
727        log(&format!(
728            "[Bevy] Transparent batch: {} vertices, {} triangles",
729            transparent_batch.vertex_count(),
730            transparent_batch.triangle_count()
731        ));
732
733        // Store mappings for picking
734        triangle_mapping.transparent = transparent_batch.take_triangle_mapping();
735        #[cfg(feature = "color-palette")]
736        {
737            color_mapping.transparent = transparent_batch.take_color_info();
738        }
739
740        let mesh = transparent_batch.build();
741        let material = StandardMaterial {
742            base_color: Color::WHITE,
743            metallic: 0.02,
744            perceptual_roughness: 0.08,
745            reflectance: 0.6,
746            double_sided: true,
747            cull_mode: None,
748            alpha_mode: AlphaMode::Blend,
749            ..default()
750        };
751
752        commands.spawn((
753            Mesh3d(meshes.add(mesh)),
754            MeshMaterial3d(materials.add(material)),
755            Transform::default(),
756            BatchedMesh {
757                is_transparent: true,
758            },
759        ));
760    }
761
762    // Update scene bounds
763    if scene_min.x.is_finite() && scene_max.x.is_finite() {
764        scene_data.bounds = Some(SceneBounds {
765            min: scene_min,
766            max: scene_max,
767        });
768        log(&format!(
769            "[Bevy] Scene bounds: {:?} to {:?}",
770            scene_min, scene_max
771        ));
772    }
773
774    // Calculate totals for logging
775    let total_vertices = scene_data
776        .meshes
777        .iter()
778        .map(|m| m.geometry.vertex_count())
779        .sum::<usize>();
780    let total_triangles = scene_data
781        .meshes
782        .iter()
783        .map(|m| m.geometry.triangle_count())
784        .sum::<usize>();
785    let geometry_size: usize = scene_data
786        .meshes
787        .iter()
788        .map(|m| {
789            m.geometry.positions.len() * 4
790                + m.geometry.normals.len() * 4
791                + m.geometry.indices.len() * 4
792        })
793        .sum();
794
795    let batch_time = now_ms() - batch_start;
796    crate::log_info(&format!(
797        "[Bevy] ✓ GPU upload: {:.0}ms | {} vertices, {} triangles | {:.1} MB geometry",
798        batch_time,
799        total_vertices,
800        total_triangles,
801        geometry_size as f64 / (1024.0 * 1024.0)
802    ));
803
804    // FREE MEMORY: Clear heavy geometry data now that it's on GPU
805    for mesh in &mut scene_data.meshes {
806        mesh.geometry = Arc::new(MeshGeometry::default());
807    }
808
809    log(&format!(
810        "[Bevy] Freed {}MB of geometry data from IfcSceneData",
811        geometry_size / (1024 * 1024)
812    ));
813
814    scene_data.dirty = false;
815}
816
817/// System to auto-fit camera to scene bounds when first loaded
818fn auto_fit_camera_system(
819    scene_data: Res<IfcSceneData>,
820    mut auto_fit: ResMut<AutoFitState>,
821    mut camera_controller: ResMut<crate::camera::CameraController>,
822) {
823    // Only fit once when bounds become available
824    if auto_fit.has_fit {
825        return;
826    }
827
828    if let Some(ref bounds) = scene_data.bounds {
829        log(&format!(
830            "[Bevy] Auto-fitting camera to bounds: {:?} to {:?}",
831            bounds.min, bounds.max
832        ));
833
834        // Calculate scene center and size
835        let center = bounds.center();
836        let diagonal = bounds.diagonal();
837
838        // Set camera to fit the entire scene
839        let fov_rad = camera_controller.fov.to_radians();
840        let distance = diagonal / (2.0 * (fov_rad / 2.0).tan());
841
842        // Update camera controller directly (no animation for initial fit)
843        camera_controller.target = center;
844        camera_controller.distance = distance.max(100.0); // Minimum distance of 100mm
845        camera_controller.azimuth = 0.785; // 45 degrees
846        camera_controller.elevation = 0.615; // ~35 degrees (isometric)
847
848        log(&format!(
849            "[Bevy] Camera set to: target={:?}, distance={}",
850            center, distance
851        ));
852
853        auto_fit.has_fit = true;
854    }
855}
856
857/// System to update mesh visibility via vertex alpha.
858/// Polls visibility state from localStorage and hides/isolates entities by
859/// setting their vertex alpha to 0.0 (same approach as selection highlighting).
860#[cfg(feature = "color-palette")]
861#[allow(unused_variables, unused_mut)]
862fn update_mesh_visibility_system(
863    mut previous_visibility: ResMut<PreviousVisibility>,
864    selection: Res<crate::picking::SelectionState>,
865    color_mapping: Res<EntityColorMapping>,
866    mut mesh_assets: ResMut<Assets<Mesh>>,
867    batched_meshes: Query<(&Mesh3d, &BatchedMesh)>,
868) {
869    #[cfg(target_arch = "wasm32")]
870    {
871        let vis = crate::storage::load_visibility();
872
873        let (new_hidden, new_isolated) = match &vis {
874            Some(v) => {
875                let hidden: rustc_hash::FxHashSet<u64> = v.hidden.iter().copied().collect();
876                let isolated: Option<rustc_hash::FxHashSet<u64>> =
877                    v.isolated.as_ref().map(|i| i.iter().copied().collect());
878                (hidden, isolated)
879            }
880            None => (rustc_hash::FxHashSet::default(), None),
881        };
882
883        // Check if visibility actually changed
884        if new_hidden == previous_visibility.hidden && new_isolated == previous_visibility.isolated
885        {
886            return;
887        }
888
889        log(&format!(
890            "[Bevy] Visibility changed: {} hidden, isolated={:?}",
891            new_hidden.len(),
892            new_isolated.as_ref().map(|s| s.len()),
893        ));
894
895        let current_selection = &selection.selected;
896
897        // Update vertex colors in batched meshes
898        for (mesh_handle, batched_mesh) in batched_meshes.iter() {
899            let Some(mesh) = mesh_assets.get_mut(&mesh_handle.0) else {
900                continue;
901            };
902
903            let Some(VertexAttributeValues::Float32x4(colors)) =
904                mesh.attribute_mut(Mesh::ATTRIBUTE_COLOR)
905            else {
906                continue;
907            };
908
909            let color_infos = if batched_mesh.is_transparent {
910                &color_mapping.transparent
911            } else {
912                &color_mapping.opaque
913            };
914
915            for info in color_infos {
916                let visible = if let Some(ref iso) = new_isolated {
917                    iso.contains(&info.entity_id)
918                } else {
919                    !new_hidden.contains(&info.entity_id)
920                };
921
922                let start = info.start_vertex as usize;
923                let end = start + info.vertex_count as usize;
924
925                if visible {
926                    // Restore original color (or selection color if selected)
927                    let color = if current_selection.contains(&info.entity_id) {
928                        SELECTION_COLOR
929                    } else {
930                        info.original_color
931                    };
932                    for c in colors[start..end].iter_mut() {
933                        *c = color;
934                    }
935                } else {
936                    // Hide by setting alpha to 0
937                    for c in colors[start..end].iter_mut() {
938                        *c = [c[0], c[1], c[2], 0.0];
939                    }
940                }
941            }
942        }
943
944        previous_visibility.hidden = new_hidden;
945        previous_visibility.isolated = new_isolated;
946    }
947}
948
949#[cfg(not(feature = "color-palette"))]
950fn update_mesh_visibility_system() {}
951
952/// Resource to track previous visibility state for efficient updates
953#[cfg(feature = "color-palette")]
954#[derive(Resource, Default)]
955pub struct PreviousVisibility {
956    pub hidden: rustc_hash::FxHashSet<u64>,
957    pub isolated: Option<rustc_hash::FxHashSet<u64>>,
958}
959
960#[cfg(not(feature = "color-palette"))]
961#[derive(Resource, Default)]
962pub struct PreviousVisibility;
963
964/// Selection highlight color (light blue / hellblau)
965#[cfg(feature = "color-palette")]
966const SELECTION_COLOR: [f32; 4] = [0.3, 0.7, 1.0, 1.0];
967
968/// Resource to track previous selection for efficient updates
969#[derive(Resource, Default)]
970pub struct PreviousSelection {
971    #[cfg(feature = "color-palette")]
972    pub selected_ids: rustc_hash::FxHashSet<u64>,
973}
974
975/// System to update mesh selection highlighting via vertex colors
976#[cfg(feature = "color-palette")]
977fn update_mesh_selection_system(
978    selection: Res<crate::picking::SelectionState>,
979    mut previous_selection: ResMut<PreviousSelection>,
980    color_mapping: Res<EntityColorMapping>,
981    mut mesh_assets: ResMut<Assets<Mesh>>,
982    batched_meshes: Query<(&Mesh3d, &BatchedMesh)>,
983) {
984    if !selection.is_changed() {
985        return;
986    }
987
988    let current_selection = &selection.selected;
989
990    // Find entities that changed selection state
991    let newly_selected: Vec<u64> = current_selection
992        .difference(&previous_selection.selected_ids)
993        .copied()
994        .collect();
995    let newly_deselected: Vec<u64> = previous_selection
996        .selected_ids
997        .difference(current_selection)
998        .copied()
999        .collect();
1000
1001    if newly_selected.is_empty() && newly_deselected.is_empty() {
1002        return;
1003    }
1004
1005    // Update vertex colors in batched meshes
1006    for (mesh_handle, batched_mesh) in batched_meshes.iter() {
1007        let Some(mesh) = mesh_assets.get_mut(&mesh_handle.0) else {
1008            continue;
1009        };
1010
1011        let Some(VertexAttributeValues::Float32x4(colors)) =
1012            mesh.attribute_mut(Mesh::ATTRIBUTE_COLOR)
1013        else {
1014            continue;
1015        };
1016
1017        let color_infos = if batched_mesh.is_transparent {
1018            &color_mapping.transparent
1019        } else {
1020            &color_mapping.opaque
1021        };
1022
1023        // Apply highlight color to newly selected
1024        for &entity_id in &newly_selected {
1025            for info in color_infos.iter().filter(|i| i.entity_id == entity_id) {
1026                let start = info.start_vertex as usize;
1027                let end = start + info.vertex_count as usize;
1028                for color in colors[start..end].iter_mut() {
1029                    *color = SELECTION_COLOR;
1030                }
1031            }
1032        }
1033
1034        // Restore original color for newly deselected
1035        for &entity_id in &newly_deselected {
1036            for info in color_infos.iter().filter(|i| i.entity_id == entity_id) {
1037                let start = info.start_vertex as usize;
1038                let end = start + info.vertex_count as usize;
1039                for color in colors[start..end].iter_mut() {
1040                    *color = info.original_color;
1041                }
1042            }
1043        }
1044    }
1045
1046    // Update previous selection state
1047    previous_selection.selected_ids = current_selection.clone();
1048}
1049
1050#[cfg(not(feature = "color-palette"))]
1051fn update_mesh_selection_system(_selection: Res<crate::picking::SelectionState>) {
1052    // Selection highlighting requires color-palette feature for vertex color updates
1053}
1054
1055/// System to poll for focus commands from Yew (zoom to entity)
1056#[allow(unused_variables, unused_mut)]
1057fn poll_focus_command_system(
1058    mut camera_controller: ResMut<crate::camera::CameraController>,
1059    entities: Query<(&IfcEntity, &EntityBounds)>,
1060) {
1061    #[cfg(target_arch = "wasm32")]
1062    {
1063        if let Some(focus) = crate::storage::load_focus() {
1064            // Clear the command so we don't process it again
1065            crate::storage::clear_focus();
1066
1067            log(&format!(
1068                "[Bevy] Focus command received for entity #{}",
1069                focus.entity_id
1070            ));
1071
1072            // Find the entity with matching ID
1073            for (ifc_entity, bounds) in entities.iter() {
1074                if ifc_entity.id == focus.entity_id {
1075                    log(&format!(
1076                        "[Bevy] Focusing on entity '{}' ({}), bounds: {:?} to {:?}",
1077                        ifc_entity.name.as_deref().unwrap_or("unnamed"),
1078                        ifc_entity.entity_type,
1079                        bounds.min,
1080                        bounds.max
1081                    ));
1082
1083                    // Use camera's frame method to zoom to entity bounds
1084                    camera_controller.frame(bounds.min, bounds.max);
1085                    break;
1086                }
1087            }
1088        }
1089    }
1090}
1091
1092/// System to poll for palette change commands from Yew
1093#[cfg(feature = "color-palette")]
1094#[allow(unused_variables, unused_mut)]
1095fn poll_palette_change_system(
1096    mut current_palette: ResMut<CurrentPalette>,
1097    color_mapping: Res<EntityColorMapping>,
1098    mut mesh_assets: ResMut<Assets<Mesh>>,
1099    batched_meshes: Query<(&Mesh3d, &BatchedMesh)>,
1100) {
1101    #[cfg(target_arch = "wasm32")]
1102    {
1103        if let Some(palette_str) = crate::storage::load_palette() {
1104            // Clear the command so we don't process it again
1105            crate::storage::clear_palette();
1106
1107            // Parse palette string
1108            let new_palette = match palette_str.as_str() {
1109                "vibrant" => ColorPalette::Vibrant,
1110                "realistic" => ColorPalette::Realistic,
1111                "high_contrast" => ColorPalette::HighContrast,
1112                "monochrome" => ColorPalette::Monochrome,
1113                _ => {
1114                    log(&format!("[Bevy] Unknown palette: {}", palette_str));
1115                    return;
1116                }
1117            };
1118
1119            // Only update if palette changed
1120            if current_palette.palette != new_palette {
1121                log(&format!(
1122                    "[Bevy] Palette changed to {:?}, updating vertex colors in-place",
1123                    new_palette
1124                ));
1125
1126                current_palette.palette = new_palette;
1127
1128                // Update vertex colors directly in GPU meshes (no geometry rebuild!)
1129                for (mesh_handle, batched) in batched_meshes.iter() {
1130                    let mapping = if batched.is_transparent {
1131                        &color_mapping.transparent
1132                    } else {
1133                        &color_mapping.opaque
1134                    };
1135
1136                    if let Some(mesh) = mesh_assets.get_mut(&mesh_handle.0) {
1137                        // Get mutable access to vertex colors
1138                        if let Some(bevy::mesh::VertexAttributeValues::Float32x4(colors)) =
1139                            mesh.attribute_mut(Mesh::ATTRIBUTE_COLOR)
1140                        {
1141                            // Update colors based on entity mapping
1142                            for info in mapping {
1143                                // Preserve IFC-authored colors (e.g. green field, red logo)
1144                                let new_color = if info.has_ifc_color {
1145                                    info.original_color
1146                                } else {
1147                                    get_color_for_palette(&info.entity_type, new_palette)
1148                                };
1149                                let start = info.start_vertex as usize;
1150                                let end = start + info.vertex_count as usize;
1151                                for i in start..end.min(colors.len()) {
1152                                    colors[i] = new_color;
1153                                }
1154                            }
1155                            log(&format!(
1156                                "[Bevy] Updated {} entity colors in {} batch",
1157                                mapping.len(),
1158                                if batched.is_transparent {
1159                                    "transparent"
1160                                } else {
1161                                    "opaque"
1162                                }
1163                            ));
1164                        }
1165                    }
1166                }
1167            }
1168        }
1169    }
1170}
1171
1172/// No-op system when color-palette feature is disabled
1173#[cfg(not(feature = "color-palette"))]
1174fn poll_palette_change_system() {
1175    // Color palette switching disabled - no entity color info stored
1176}
1177
1178/// Color palette for IFC visualization
1179#[cfg(feature = "color-palette")]
1180#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1181pub enum ColorPalette {
1182    /// Vibrant - saturated, vivid colors (default)
1183    #[default]
1184    Vibrant,
1185    /// Realistic - muted architectural colors
1186    Realistic,
1187    /// High Contrast - bold colors for visibility
1188    HighContrast,
1189    /// Monochrome - grayscale for technical views
1190    Monochrome,
1191}
1192
1193#[cfg(feature = "color-palette")]
1194impl ColorPalette {
1195    /// Get all available palettes
1196    pub fn all() -> &'static [ColorPalette] {
1197        &[
1198            ColorPalette::Vibrant,
1199            ColorPalette::Realistic,
1200            ColorPalette::HighContrast,
1201            ColorPalette::Monochrome,
1202        ]
1203    }
1204
1205    /// Get palette name for display
1206    pub fn name(&self) -> &'static str {
1207        match self {
1208            ColorPalette::Vibrant => "Vibrant",
1209            ColorPalette::Realistic => "Realistic",
1210            ColorPalette::HighContrast => "High Contrast",
1211            ColorPalette::Monochrome => "Monochrome",
1212        }
1213    }
1214}
1215
1216/// Get color for IFC entity type using the specified palette
1217#[cfg(feature = "color-palette")]
1218pub fn get_color_for_palette(entity_type: &str, palette: ColorPalette) -> [f32; 4] {
1219    match palette {
1220        ColorPalette::Vibrant => get_vibrant_color(entity_type),
1221        ColorPalette::Realistic => get_realistic_color(entity_type),
1222        ColorPalette::HighContrast => get_high_contrast_color(entity_type),
1223        ColorPalette::Monochrome => get_monochrome_color(entity_type),
1224    }
1225}
1226
1227/// Get default color for IFC entity type (uses Vibrant palette)
1228pub fn get_default_color(entity_type: &str) -> [f32; 4] {
1229    get_vibrant_color(entity_type)
1230}
1231
1232/// Vibrant color palette - saturated, vivid colors
1233fn get_vibrant_color(entity_type: &str) -> [f32; 4] {
1234    let upper = entity_type.to_uppercase();
1235
1236    if upper.contains("WALL") {
1237        [0.95, 0.90, 0.80, 1.0] // Warm cream
1238    } else if upper.contains("SLAB") {
1239        [0.85, 0.82, 0.78, 1.0] // Light concrete
1240    } else if upper.contains("ROOF") {
1241        [0.85, 0.45, 0.35, 1.0] // Terracotta red
1242    } else if upper.contains("BEAM") || upper.contains("COLUMN") || upper.contains("MEMBER") {
1243        [0.45, 0.55, 0.75, 1.0] // Steel blue
1244    } else if upper.contains("DOOR") {
1245        [0.65, 0.40, 0.25, 1.0] // Rich wood brown
1246    } else if upper.contains("WINDOW") || upper.contains("CURTAINWALL") {
1247        [0.4, 0.7, 0.9, 0.4] // Sky blue glass
1248    } else if upper.contains("STAIR") || upper.contains("RAMP") {
1249        [0.75, 0.70, 0.65, 1.0] // Warm stone
1250    } else if upper.contains("RAILING") {
1251        [0.30, 0.30, 0.35, 1.0] // Dark metal
1252    } else if upper.contains("FURNITURE") || upper.contains("FURNISHING") {
1253        [0.70, 0.50, 0.30, 1.0] // Warm wood
1254    } else if upper.contains("SPACE") {
1255        [0.7, 0.85, 0.95, 0.15] // Light blue space
1256    } else if upper.contains("PLATE") {
1257        [0.70, 0.72, 0.78, 1.0] // Steel plate
1258    } else if upper.contains("COVERING") {
1259        [0.88, 0.85, 0.80, 1.0] // Light finish
1260    } else if upper.contains("FOOTING") || upper.contains("PILE") {
1261        [0.60, 0.58, 0.55, 1.0] // Dark concrete
1262    } else if upper.contains("PROXY") {
1263        [0.75, 0.60, 0.80, 1.0] // Purple accent
1264    } else if upper.contains("LIGHTFIXTURE") {
1265        [1.0, 0.9, 0.3, 1.0] // Warm yellow
1266    } else if upper.contains("FLOW") || upper.contains("DUCT") || upper.contains("PIPE") {
1267        [0.40, 0.75, 0.50, 1.0] // Green MEP
1268    } else if upper.contains("ELECTRIC") || upper.contains("ENERGY") {
1269        [0.90, 0.80, 0.30, 1.0] // Yellow electrical
1270    } else if upper.contains("SANITARY") || upper.contains("FIRE") {
1271        [0.95, 0.95, 0.98, 1.0] // White ceramic
1272    } else if upper.contains("SHADING") {
1273        [0.40, 0.45, 0.55, 0.85] // Blue-gray shade
1274    } else if upper.contains("TRANSPORT") {
1275        [0.45, 0.45, 0.50, 1.0] // Dark gray
1276    } else if upper.contains("GEOGRAPHIC") || upper.contains("VIRTUAL") {
1277        [0.65, 0.85, 0.65, 0.3] // Light green
1278    } else {
1279        [0.80, 0.78, 0.75, 1.0] // Neutral gray
1280    }
1281}
1282
1283/// Realistic color palette - muted architectural colors
1284#[cfg(feature = "color-palette")]
1285fn get_realistic_color(entity_type: &str) -> [f32; 4] {
1286    let upper = entity_type.to_uppercase();
1287
1288    if upper.contains("WALL") {
1289        [0.92, 0.85, 0.75, 1.0] // Warm beige
1290    } else if upper.contains("SLAB") {
1291        [0.75, 0.73, 0.70, 1.0] // Concrete gray
1292    } else if upper.contains("ROOF") {
1293        [0.72, 0.55, 0.45, 1.0] // Terracotta
1294    } else if upper.contains("BEAM") || upper.contains("COLUMN") || upper.contains("MEMBER") {
1295        [0.60, 0.65, 0.72, 1.0] // Steel blue-gray
1296    } else if upper.contains("DOOR") {
1297        [0.55, 0.35, 0.20, 1.0] // Wood brown
1298    } else if upper.contains("WINDOW") || upper.contains("CURTAINWALL") {
1299        [0.5, 0.7, 0.85, 0.35] // Blue glass
1300    } else if upper.contains("STAIR") || upper.contains("RAMP") {
1301        [0.65, 0.62, 0.58, 1.0] // Warm gray
1302    } else if upper.contains("RAILING") {
1303        [0.35, 0.35, 0.38, 1.0] // Dark metallic
1304    } else if upper.contains("FURNITURE") || upper.contains("FURNISHING") {
1305        [0.65, 0.45, 0.28, 1.0] // Warm wood
1306    } else if upper.contains("SPACE") {
1307        [0.8, 0.85, 0.95, 0.12] // Light blue
1308    } else if upper.contains("PLATE") {
1309        [0.68, 0.70, 0.75, 1.0] // Steel
1310    } else if upper.contains("COVERING") {
1311        [0.82, 0.80, 0.76, 1.0] // Light warm gray
1312    } else if upper.contains("FOOTING") || upper.contains("PILE") {
1313        [0.55, 0.53, 0.50, 1.0] // Dark concrete
1314    } else if upper.contains("PROXY") {
1315        [0.70, 0.65, 0.75, 1.0] // Purple tint
1316    } else if upper.contains("LIGHTFIXTURE") {
1317        [0.85, 0.78, 0.30, 1.0] // Warm amber
1318    } else if upper.contains("FLOW") || upper.contains("DUCT") || upper.contains("PIPE") {
1319        [0.55, 0.70, 0.58, 1.0] // Green tint
1320    } else if upper.contains("ELECTRIC") || upper.contains("ENERGY") {
1321        [0.75, 0.72, 0.45, 1.0] // Yellow tint
1322    } else if upper.contains("SANITARY") || upper.contains("FIRE") {
1323        [0.92, 0.92, 0.95, 1.0] // White ceramic
1324    } else if upper.contains("SHADING") {
1325        [0.45, 0.48, 0.55, 0.8] // Dark blue-gray
1326    } else if upper.contains("TRANSPORT") {
1327        [0.40, 0.40, 0.42, 1.0] // Dark gray
1328    } else if upper.contains("GEOGRAPHIC") || upper.contains("VIRTUAL") {
1329        [0.75, 0.85, 0.75, 0.25] // Light green
1330    } else {
1331        [0.75, 0.72, 0.70, 1.0] // Neutral warm gray
1332    }
1333}
1334
1335/// High contrast color palette - bold colors for visibility
1336#[cfg(feature = "color-palette")]
1337fn get_high_contrast_color(entity_type: &str) -> [f32; 4] {
1338    let upper = entity_type.to_uppercase();
1339
1340    if upper.contains("WALL") {
1341        [1.0, 0.95, 0.85, 1.0] // Bright cream
1342    } else if upper.contains("SLAB") {
1343        [0.7, 0.7, 0.7, 1.0] // Medium gray
1344    } else if upper.contains("ROOF") {
1345        [0.9, 0.3, 0.2, 1.0] // Bright red
1346    } else if upper.contains("BEAM") || upper.contains("COLUMN") || upper.contains("MEMBER") {
1347        [0.2, 0.4, 0.8, 1.0] // Bright blue
1348    } else if upper.contains("DOOR") {
1349        [0.6, 0.3, 0.1, 1.0] // Dark brown
1350    } else if upper.contains("WINDOW") || upper.contains("CURTAINWALL") {
1351        [0.3, 0.7, 1.0, 0.5] // Bright cyan glass
1352    } else if upper.contains("STAIR") || upper.contains("RAMP") {
1353        [0.9, 0.7, 0.5, 1.0] // Orange-tan
1354    } else if upper.contains("RAILING") {
1355        [0.2, 0.2, 0.2, 1.0] // Near black
1356    } else if upper.contains("FURNITURE") || upper.contains("FURNISHING") {
1357        [0.8, 0.5, 0.2, 1.0] // Bright orange
1358    } else if upper.contains("SPACE") {
1359        [0.6, 0.8, 1.0, 0.2] // Light cyan
1360    } else if upper.contains("PLATE") {
1361        [0.5, 0.5, 0.6, 1.0] // Blue-gray
1362    } else if upper.contains("COVERING") {
1363        [0.95, 0.9, 0.85, 1.0] // Off-white
1364    } else if upper.contains("FOOTING") || upper.contains("PILE") {
1365        [0.4, 0.4, 0.35, 1.0] // Dark brown-gray
1366    } else if upper.contains("PROXY") {
1367        [0.8, 0.4, 0.9, 1.0] // Bright purple
1368    } else if upper.contains("LIGHTFIXTURE") {
1369        [1.0, 0.95, 0.2, 1.0] // Bright warm yellow
1370    } else if upper.contains("FLOW") || upper.contains("DUCT") || upper.contains("PIPE") {
1371        [0.2, 0.9, 0.4, 1.0] // Bright green
1372    } else if upper.contains("ELECTRIC") || upper.contains("ENERGY") {
1373        [1.0, 0.9, 0.2, 1.0] // Bright yellow
1374    } else if upper.contains("SANITARY") || upper.contains("FIRE") {
1375        [1.0, 1.0, 1.0, 1.0] // Pure white
1376    } else if upper.contains("SHADING") {
1377        [0.3, 0.35, 0.5, 0.9] // Dark blue
1378    } else if upper.contains("TRANSPORT") {
1379        [0.3, 0.3, 0.3, 1.0] // Dark gray
1380    } else if upper.contains("GEOGRAPHIC") || upper.contains("VIRTUAL") {
1381        [0.5, 1.0, 0.5, 0.35] // Bright green
1382    } else {
1383        [0.85, 0.85, 0.85, 1.0] // Light gray
1384    }
1385}
1386
1387/// Monochrome color palette - grayscale for technical views
1388#[cfg(feature = "color-palette")]
1389fn get_monochrome_color(entity_type: &str) -> [f32; 4] {
1390    let upper = entity_type.to_uppercase();
1391
1392    // Use different gray levels based on element type for visual hierarchy
1393    if upper.contains("WALL") {
1394        [0.85, 0.85, 0.85, 1.0] // Light gray
1395    } else if upper.contains("SLAB") {
1396        [0.70, 0.70, 0.70, 1.0] // Medium gray
1397    } else if upper.contains("ROOF") {
1398        [0.60, 0.60, 0.60, 1.0] // Medium-dark gray
1399    } else if upper.contains("BEAM") || upper.contains("COLUMN") || upper.contains("MEMBER") {
1400        [0.50, 0.50, 0.50, 1.0] // Mid gray
1401    } else if upper.contains("DOOR") {
1402        [0.40, 0.40, 0.40, 1.0] // Dark gray
1403    } else if upper.contains("WINDOW") || upper.contains("CURTAINWALL") {
1404        [0.75, 0.75, 0.75, 0.4] // Transparent light gray
1405    } else if upper.contains("STAIR") || upper.contains("RAMP") {
1406        [0.65, 0.65, 0.65, 1.0] // Medium-light gray
1407    } else if upper.contains("RAILING") {
1408        [0.30, 0.30, 0.30, 1.0] // Very dark gray
1409    } else if upper.contains("FURNITURE") || upper.contains("FURNISHING") {
1410        [0.55, 0.55, 0.55, 1.0] // Medium gray
1411    } else if upper.contains("SPACE") {
1412        [0.90, 0.90, 0.90, 0.15] // Very light gray, transparent
1413    } else if upper.contains("PLATE") {
1414        [0.60, 0.60, 0.60, 1.0] // Medium-dark gray
1415    } else if upper.contains("COVERING") {
1416        [0.80, 0.80, 0.80, 1.0] // Light gray
1417    } else if upper.contains("FOOTING") || upper.contains("PILE") {
1418        [0.45, 0.45, 0.45, 1.0] // Dark gray
1419    } else if upper.contains("PROXY") {
1420        [0.70, 0.70, 0.70, 1.0] // Medium gray
1421    } else if upper.contains("LIGHTFIXTURE") {
1422        [0.60, 0.60, 0.60, 1.0] // Mid-brightness gray
1423    } else if upper.contains("FLOW") || upper.contains("DUCT") || upper.contains("PIPE") {
1424        [0.55, 0.55, 0.55, 1.0] // Medium gray
1425    } else if upper.contains("ELECTRIC") || upper.contains("ENERGY") {
1426        [0.65, 0.65, 0.65, 1.0] // Medium-light gray
1427    } else if upper.contains("SANITARY") || upper.contains("FIRE") {
1428        [0.95, 0.95, 0.95, 1.0] // Near white
1429    } else if upper.contains("SHADING") {
1430        [0.35, 0.35, 0.35, 0.85] // Dark gray, slightly transparent
1431    } else if upper.contains("TRANSPORT") {
1432        [0.40, 0.40, 0.40, 1.0] // Dark gray
1433    } else if upper.contains("GEOGRAPHIC") || upper.contains("VIRTUAL") {
1434        [0.85, 0.85, 0.85, 0.25] // Light gray, transparent
1435    } else {
1436        [0.75, 0.75, 0.75, 1.0] // Default gray
1437    }
1438}
1439
1440/// Compute flat normals from triangle positions and indices
1441fn compute_flat_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; 3]> {
1442    let mut normals = vec![[0.0f32, 0.0, 0.0]; positions.len()];
1443
1444    // Accumulate face normals to vertices
1445    for tri in indices.chunks(3) {
1446        if tri.len() < 3 {
1447            continue;
1448        }
1449        let i0 = tri[0] as usize;
1450        let i1 = tri[1] as usize;
1451        let i2 = tri[2] as usize;
1452
1453        if i0 >= positions.len() || i1 >= positions.len() || i2 >= positions.len() {
1454            continue;
1455        }
1456
1457        let p0 = Vec3::from_array(positions[i0]);
1458        let p1 = Vec3::from_array(positions[i1]);
1459        let p2 = Vec3::from_array(positions[i2]);
1460
1461        let edge1 = p1 - p0;
1462        let edge2 = p2 - p0;
1463        let face_normal = edge1.cross(edge2);
1464
1465        // Add face normal to each vertex (will be normalized later)
1466        for &idx in &[i0, i1, i2] {
1467            normals[idx][0] += face_normal.x;
1468            normals[idx][1] += face_normal.y;
1469            normals[idx][2] += face_normal.z;
1470        }
1471    }
1472
1473    // Normalize all normals
1474    for normal in &mut normals {
1475        let len = (normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]).sqrt();
1476        if len > 0.0001 {
1477            normal[0] /= len;
1478            normal[1] /= len;
1479            normal[2] /= len;
1480        } else {
1481            // Default to up if degenerate
1482            *normal = [0.0, 1.0, 0.0];
1483        }
1484    }
1485
1486    normals
1487}