Skip to main content

runmat_plot/
geometry_scene.rs

1//! Chunked geometry scenes for CAD and FEA visualization.
2//!
3//! This is intentionally rendering-domain data. CAD import, semantic ownership,
4//! and FEA result storage stay in their own crates; this module describes the
5//! mesh chunks that the plot renderer can keep resident and redraw efficiently.
6
7use crate::core::{
8    AlphaMode, BoundingBox, Camera, DrawCall, Material, PipelineType, RenderData, SceneNode, Vertex,
9};
10use glam::{Mat4, Vec2, Vec3, Vec4};
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone)]
14pub struct GeometryScene {
15    pub scene_id: String,
16    pub revision: u64,
17    pub title: Option<String>,
18    pub overlay: Option<GeometrySceneOverlay>,
19    pub chunks: Vec<GeometrySceneChunk>,
20    pub bounds: BoundingBox,
21    pub show_grid: bool,
22    pub axis_equal: bool,
23}
24
25impl GeometryScene {
26    pub fn new(
27        scene_id: impl Into<String>,
28        revision: u64,
29        chunks: Vec<GeometrySceneChunk>,
30    ) -> Self {
31        let bounds = combined_chunk_bounds(&chunks);
32        Self {
33            scene_id: scene_id.into(),
34            revision,
35            title: None,
36            overlay: None,
37            chunks,
38            bounds,
39            show_grid: false,
40            axis_equal: true,
41        }
42    }
43
44    pub fn with_title(mut self, title: impl Into<String>) -> Self {
45        self.title = Some(title.into());
46        self
47    }
48
49    pub fn with_overlay(mut self, overlay: GeometrySceneOverlay) -> Self {
50        self.overlay = Some(overlay);
51        self
52    }
53
54    pub fn append_chunks(&mut self, chunks: impl IntoIterator<Item = GeometrySceneChunk>) {
55        self.chunks.extend(chunks);
56        self.revision = self.revision.saturating_add(1);
57        self.bounds = combined_chunk_bounds(&self.chunks);
58    }
59
60    pub fn set_overlay(&mut self, overlay: GeometrySceneOverlay) {
61        self.overlay = Some(overlay);
62        self.revision = self.revision.saturating_add(1);
63    }
64
65    pub fn cache_key(&self) -> GeometrySceneCacheKey {
66        GeometrySceneCacheKey {
67            scene_id: self.scene_id.clone(),
68            revision: self.revision,
69            chunk_count: self.chunks.len(),
70            vertex_count: self.vertex_count(),
71            index_count: self.index_count(),
72        }
73    }
74
75    pub fn vertex_count(&self) -> usize {
76        self.chunks
77            .iter()
78            .map(|chunk| chunk.render_data.vertex_count())
79            .sum()
80    }
81
82    pub fn index_count(&self) -> usize {
83        self.chunks
84            .iter()
85            .map(|chunk| chunk.indices.as_ref().map(Vec::len).unwrap_or(0))
86            .sum()
87    }
88
89    pub fn triangle_count(&self) -> usize {
90        self.chunks
91            .iter()
92            .map(GeometrySceneChunk::triangle_count)
93            .sum()
94    }
95
96    pub fn is_empty(&self) -> bool {
97        self.chunks.is_empty()
98    }
99
100    pub fn nodes(&self) -> Vec<SceneNode> {
101        self.nodes_with_presentation(&GeometryScenePresentation::default())
102    }
103
104    pub fn nodes_with_presentation(
105        &self,
106        presentation: &GeometryScenePresentation,
107    ) -> Vec<SceneNode> {
108        let mut nodes: Vec<SceneNode> = self
109            .chunks
110            .iter()
111            .enumerate()
112            .map(|(index, chunk)| SceneNode {
113                id: self.chunk_node_id(index, &chunk.chunk_id),
114                name: chunk
115                    .label
116                    .clone()
117                    .unwrap_or_else(|| format!("Geometry chunk {}", index + 1)),
118                transform: Mat4::IDENTITY,
119                visible: chunk.visible,
120                cast_shadows: false,
121                receive_shadows: false,
122                axes_index: 0,
123                parent: None,
124                children: Vec::new(),
125                render_data: Some(chunk.render_data_with_presentation(presentation)),
126                bounds: chunk.bounds,
127                lod_levels: Vec::new(),
128                current_lod: 0,
129            })
130            .collect();
131        nodes.extend(self.annotation_nodes(presentation));
132        nodes
133    }
134
135    pub fn chunk_node_id(&self, index: usize, chunk_id: &str) -> u64 {
136        stable_node_id(&self.scene_id, self.revision, index, chunk_id)
137    }
138
139    fn annotation_nodes(&self, presentation: &GeometryScenePresentation) -> Vec<SceneNode> {
140        if presentation.region_annotations.is_empty() {
141            return Vec::new();
142        }
143
144        let mut point_vertices = Vec::new();
145        let mut line_vertices = Vec::new();
146        let arrow_length = annotation_arrow_length(self.bounds);
147
148        for annotation in &presentation.region_annotations {
149            for chunk in &self.chunks {
150                if !chunk.visible || chunk.render_data.pipeline_type != PipelineType::Triangles {
151                    continue;
152                }
153                let Some(anchor) = chunk.region_anchor(&annotation.region_id) else {
154                    continue;
155                };
156                let color = annotation.color;
157                let mut marker = vertex(
158                    anchor.to_array(),
159                    color,
160                    [0.0, 0.0, annotation.size.unwrap_or(15.0)],
161                );
162                marker.tex_coords = [1.0, 1.0];
163                point_vertices.push(marker);
164
165                if let Some(direction) = annotation
166                    .direction
167                    .and_then(normalized_annotation_direction)
168                {
169                    append_annotation_arrow(
170                        &mut line_vertices,
171                        anchor,
172                        direction,
173                        arrow_length,
174                        color,
175                    );
176                }
177            }
178        }
179
180        let mut nodes = Vec::new();
181        if !point_vertices.is_empty() {
182            nodes.push(self.annotation_node(
183                "FEA region markers",
184                "__fea_annotations:markers",
185                self.chunks.len(),
186                PipelineType::Points,
187                point_vertices,
188            ));
189        }
190        if !line_vertices.is_empty() {
191            nodes.push(self.annotation_node(
192                "FEA load vectors",
193                "__fea_annotations:vectors",
194                self.chunks.len() + 1,
195                PipelineType::Lines,
196                line_vertices,
197            ));
198        }
199        nodes
200    }
201
202    fn annotation_node(
203        &self,
204        name: &str,
205        chunk_id: &str,
206        index: usize,
207        pipeline_type: PipelineType,
208        vertices: Vec<Vertex>,
209    ) -> SceneNode {
210        let vertex_count = vertices.len();
211        let bounds = bounds_from_vertices(&vertices);
212        SceneNode {
213            id: stable_node_id(&self.scene_id, self.revision, index, chunk_id),
214            name: name.to_string(),
215            transform: Mat4::IDENTITY,
216            visible: true,
217            cast_shadows: false,
218            receive_shadows: false,
219            axes_index: 0,
220            parent: None,
221            children: Vec::new(),
222            render_data: Some(RenderData {
223                pipeline_type,
224                vertices,
225                indices: None,
226                gpu_vertices: None,
227                bounds: Some(bounds),
228                material: Material {
229                    albedo: Vec4::ONE,
230                    alpha_mode: AlphaMode::Blend,
231                    double_sided: true,
232                    ..Default::default()
233                },
234                draw_calls: vec![DrawCall {
235                    vertex_offset: 0,
236                    vertex_count,
237                    index_offset: None,
238                    index_count: None,
239                    instance_count: 1,
240                }],
241                image: None,
242            }),
243            bounds,
244            lod_levels: Vec::new(),
245            current_lod: 0,
246        }
247    }
248}
249
250#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
251#[serde(rename_all = "camelCase")]
252pub struct GeometryScenePresentation {
253    pub selected_region_id: Option<String>,
254    pub hovered_region_id: Option<String>,
255    #[serde(default)]
256    pub region_highlights: Vec<GeometrySceneRegionHighlight>,
257    #[serde(default)]
258    pub region_annotations: Vec<GeometrySceneRegionAnnotation>,
259    #[serde(default)]
260    pub display_mode: GeometrySceneDisplayMode,
261    #[serde(default = "default_edge_overlay_enabled")]
262    pub edge_overlay_enabled: bool,
263}
264
265impl Default for GeometryScenePresentation {
266    fn default() -> Self {
267        Self {
268            selected_region_id: None,
269            hovered_region_id: None,
270            region_highlights: Vec::new(),
271            region_annotations: Vec::new(),
272            display_mode: GeometrySceneDisplayMode::Shaded,
273            edge_overlay_enabled: true,
274        }
275    }
276}
277
278#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
279#[serde(rename_all = "camelCase")]
280pub struct GeometrySceneRegionHighlight {
281    pub region_id: String,
282    pub color: [f32; 4],
283    #[serde(default)]
284    pub role: Option<String>,
285    #[serde(default)]
286    pub label: Option<String>,
287}
288
289#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
290#[serde(rename_all = "camelCase")]
291pub struct GeometrySceneRegionAnnotation {
292    pub region_id: String,
293    pub color: [f32; 4],
294    #[serde(default)]
295    pub role: Option<String>,
296    #[serde(default)]
297    pub label: Option<String>,
298    #[serde(default)]
299    pub direction: Option<[f32; 3]>,
300    #[serde(default)]
301    pub size: Option<f32>,
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
305#[serde(rename_all = "camelCase")]
306pub enum GeometrySceneDisplayMode {
307    Shaded,
308    Edges,
309    Wireframe,
310}
311
312impl Default for GeometrySceneDisplayMode {
313    fn default() -> Self {
314        Self::Shaded
315    }
316}
317
318impl GeometrySceneDisplayMode {
319    fn alpha(self, edge_overlay_enabled: bool) -> f32 {
320        match self {
321            Self::Shaded => 1.0,
322            Self::Edges if edge_overlay_enabled => 0.84,
323            Self::Edges => 0.94,
324            Self::Wireframe => 0.16,
325        }
326    }
327}
328
329#[derive(Debug, Clone)]
330pub struct GeometryScenePickRequest {
331    pub camera: Camera,
332    pub surface_size: [f32; 2],
333    pub position: [f32; 2],
334}
335
336#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
337#[serde(rename_all = "camelCase")]
338pub struct GeometryScenePickResult {
339    pub mesh_id: Option<String>,
340    pub chunk_id: String,
341    pub triangle_index: usize,
342    pub region_id: Option<String>,
343    pub region_label: Option<String>,
344    pub region_tag: Option<String>,
345    pub distance: f32,
346    pub position: [f32; 3],
347}
348
349fn default_edge_overlay_enabled() -> bool {
350    true
351}
352
353#[derive(Debug, Clone)]
354pub struct GeometryScenePickIndex {
355    scene_key: GeometrySceneCacheKey,
356    triangles: Vec<IndexedTriangle>,
357    nodes: Vec<PickBvhNode>,
358    root: Option<usize>,
359}
360
361impl GeometryScenePickIndex {
362    pub fn build(scene: &GeometryScene) -> Self {
363        let mut triangles = Vec::with_capacity(scene.triangle_count());
364        for chunk in &scene.chunks {
365            if !chunk.visible || chunk.render_data.pipeline_type != PipelineType::Triangles {
366                continue;
367            }
368            let Some(indices) = chunk.indices.as_ref() else {
369                continue;
370            };
371            for (triangle_index, triangle) in indices.chunks_exact(3).enumerate() {
372                let a = chunk.vertices.get(triangle[0] as usize);
373                let b = chunk.vertices.get(triangle[1] as usize);
374                let c = chunk.vertices.get(triangle[2] as usize);
375                let (Some(a), Some(b), Some(c)) = (a, b, c) else {
376                    continue;
377                };
378                let a = Vec3::from_array(a.position);
379                let b = Vec3::from_array(b.position);
380                let c = Vec3::from_array(c.position);
381                let bounds = triangle_bounds(a, b, c);
382                let region = chunk.region_for_triangle(triangle_index as u32);
383                triangles.push(IndexedTriangle {
384                    a,
385                    b,
386                    c,
387                    bounds,
388                    centroid: (a + b + c) / 3.0,
389                    mesh_id: chunk.mesh_id.clone(),
390                    chunk_id: chunk.chunk_id.clone(),
391                    triangle_index,
392                    region_id: region.map(|item| item.region_id.clone()),
393                    region_label: region.and_then(|item| item.label.clone()),
394                    region_tag: region.and_then(|item| item.tag.clone()),
395                });
396            }
397        }
398
399        let mut nodes = Vec::new();
400        let triangle_count = triangles.len();
401        let root = build_pick_bvh_node(&mut nodes, &mut triangles, 0, triangle_count);
402        Self {
403            scene_key: scene.cache_key(),
404            triangles,
405            nodes,
406            root,
407        }
408    }
409
410    pub fn scene_key(&self) -> &GeometrySceneCacheKey {
411        &self.scene_key
412    }
413
414    pub fn triangle_count(&self) -> usize {
415        self.triangles.len()
416    }
417
418    pub fn is_empty(&self) -> bool {
419        self.triangles.is_empty()
420    }
421
422    pub fn pick(&self, request: GeometryScenePickRequest) -> Option<GeometryScenePickResult> {
423        if request.surface_size[0] <= 0.0 || request.surface_size[1] <= 0.0 {
424            return None;
425        }
426        let mut camera = request.camera;
427        let screen_size = Vec2::new(request.surface_size[0], request.surface_size[1]);
428        let screen_pos = Vec2::new(request.position[0], request.position[1]);
429        let origin = camera.screen_to_world(screen_pos, screen_size, 0.0);
430        let far = camera.screen_to_world(screen_pos, screen_size, 1.0);
431        let direction = (far - origin).normalize_or_zero();
432        if direction.length_squared() <= f32::EPSILON {
433            return None;
434        }
435        let ray = PickRay { origin, direction };
436        let mut best: Option<PickHit> = None;
437        if let Some(root) = self.root {
438            self.pick_node(root, &ray, &mut best);
439        }
440        let hit = best?;
441        let triangle = self.triangles.get(hit.triangle_index)?;
442        Some(GeometryScenePickResult {
443            mesh_id: triangle.mesh_id.clone(),
444            chunk_id: triangle.chunk_id.clone(),
445            triangle_index: triangle.triangle_index,
446            region_id: triangle.region_id.clone(),
447            region_label: triangle.region_label.clone(),
448            region_tag: triangle.region_tag.clone(),
449            distance: hit.distance,
450            position: (ray.origin + ray.direction * hit.distance).to_array(),
451        })
452    }
453
454    fn pick_node(&self, node_index: usize, ray: &PickRay, best: &mut Option<PickHit>) {
455        let Some(node) = self.nodes.get(node_index) else {
456            return;
457        };
458        let max_distance = best
459            .as_ref()
460            .map(|hit| hit.distance)
461            .unwrap_or(f32::INFINITY);
462        let Some(bounds_distance) = ray_intersects_bounds(ray, node.bounds) else {
463            return;
464        };
465        if bounds_distance > max_distance {
466            return;
467        }
468        match node.kind {
469            PickBvhNodeKind::Leaf { start, end } => {
470                for triangle_index in start..end {
471                    let Some(triangle) = self.triangles.get(triangle_index) else {
472                        continue;
473                    };
474                    if let Some(hit_distance) =
475                        ray_intersects_triangle(ray, triangle.a, triangle.b, triangle.c)
476                    {
477                        if hit_distance > 0.0
478                            && hit_distance
479                                < best
480                                    .as_ref()
481                                    .map(|hit| hit.distance)
482                                    .unwrap_or(f32::INFINITY)
483                        {
484                            *best = Some(PickHit {
485                                triangle_index,
486                                distance: hit_distance,
487                            });
488                        }
489                    }
490                }
491            }
492            PickBvhNodeKind::Branch { left, right } => {
493                self.pick_node(left, ray, best);
494                self.pick_node(right, ray, best);
495            }
496        }
497    }
498}
499
500#[derive(Debug, Clone, Copy, PartialEq, Eq)]
501pub enum GeometrySceneCompleteness {
502    Complete,
503    Loading,
504    BoundedPreview,
505    FailedComplete,
506}
507
508#[derive(Debug, Clone)]
509pub struct GeometrySceneOverlay {
510    pub source_name: Option<String>,
511    pub status: GeometrySceneCompleteness,
512    pub quality_label: Option<String>,
513    pub format: Option<String>,
514    pub source_label: Option<String>,
515    pub allow_create_fea_study: bool,
516    pub byte_count: Option<u64>,
517    pub mesh_count: usize,
518    pub vertex_count: usize,
519    pub triangle_count: usize,
520    pub progress_percent: Option<f64>,
521    pub region_count: usize,
522    pub mapped_region_count: usize,
523    pub assembly_nodes: Vec<GeometrySceneAssemblyNode>,
524    pub regions: Vec<GeometrySceneRegionSummary>,
525    pub warnings: Vec<String>,
526}
527
528#[derive(Debug, Clone)]
529pub struct GeometrySceneAssemblyNode {
530    pub node_id: String,
531    pub label: String,
532    pub children: Vec<GeometrySceneAssemblyNode>,
533}
534
535#[derive(Debug, Clone)]
536pub struct GeometrySceneRegionSummary {
537    pub region_id: String,
538    pub label: String,
539    pub tag: Option<String>,
540    pub kind: Option<String>,
541    pub triangle_count: usize,
542}
543
544#[derive(Debug, Clone, PartialEq, Eq)]
545pub struct GeometrySceneCacheKey {
546    pub scene_id: String,
547    pub revision: u64,
548    pub chunk_count: usize,
549    pub vertex_count: usize,
550    pub index_count: usize,
551}
552
553#[derive(Debug, Clone)]
554pub struct GeometrySceneChunk {
555    pub chunk_id: String,
556    pub mesh_id: Option<String>,
557    pub label: Option<String>,
558    pub vertices: Vec<Vertex>,
559    pub indices: Option<Vec<u32>>,
560    pub render_data: RenderData,
561    pub bounds: BoundingBox,
562    pub material: Material,
563    pub regions: Vec<GeometrySceneRegion>,
564    pub owner_node_ids: Vec<String>,
565    pub visible: bool,
566}
567
568impl GeometrySceneChunk {
569    pub fn indexed_triangles(
570        chunk_id: impl Into<String>,
571        vertices: Vec<Vertex>,
572        indices: Vec<u32>,
573        material: Material,
574    ) -> Self {
575        let bounds = bounds_from_vertices(&vertices);
576        let vertex_count = vertices.len();
577        let index_count = indices.len();
578        let render_data = RenderData {
579            pipeline_type: PipelineType::Triangles,
580            vertices: vertices.clone(),
581            indices: Some(indices.clone()),
582            gpu_vertices: None,
583            bounds: Some(bounds),
584            material: material.clone(),
585            draw_calls: vec![DrawCall {
586                vertex_offset: 0,
587                vertex_count,
588                index_offset: Some(0),
589                index_count: Some(index_count),
590                instance_count: 1,
591            }],
592            image: None,
593        };
594        Self {
595            chunk_id: chunk_id.into(),
596            mesh_id: None,
597            label: None,
598            vertices,
599            indices: Some(indices),
600            render_data,
601            bounds,
602            material,
603            regions: Vec::new(),
604            owner_node_ids: Vec::new(),
605            visible: true,
606        }
607    }
608
609    pub fn from_render_data(chunk_id: impl Into<String>, render_data: RenderData) -> Self {
610        let material = render_data.material.clone();
611        let vertices = render_data.vertices.clone();
612        let indices = render_data.indices.clone();
613        let bounds = render_data
614            .bounds
615            .unwrap_or_else(|| bounds_from_vertices(&vertices));
616        Self {
617            chunk_id: chunk_id.into(),
618            mesh_id: None,
619            label: None,
620            vertices,
621            indices,
622            render_data,
623            bounds,
624            material,
625            regions: Vec::new(),
626            owner_node_ids: Vec::new(),
627            visible: true,
628        }
629    }
630
631    pub fn with_mesh_id(mut self, mesh_id: impl Into<String>) -> Self {
632        self.mesh_id = Some(mesh_id.into());
633        self
634    }
635
636    pub fn with_label(mut self, label: impl Into<String>) -> Self {
637        self.label = Some(label.into());
638        self
639    }
640
641    pub fn with_regions(mut self, regions: Vec<GeometrySceneRegion>) -> Self {
642        self.regions = regions;
643        self
644    }
645
646    pub fn with_owner_node_ids(mut self, owner_node_ids: Vec<String>) -> Self {
647        self.owner_node_ids = owner_node_ids;
648        self
649    }
650
651    pub fn triangle_count(&self) -> usize {
652        if self.render_data.pipeline_type != PipelineType::Triangles {
653            return 0;
654        }
655        self.indices
656            .as_ref()
657            .map(|indices| indices.len() / 3)
658            .unwrap_or_else(|| self.render_data.vertex_count() / 3)
659    }
660
661    pub fn render_data(&self) -> RenderData {
662        self.render_data.clone()
663    }
664
665    pub fn render_data_with_presentation(
666        &self,
667        presentation: &GeometryScenePresentation,
668    ) -> RenderData {
669        let mut render_data = self.render_data.clone();
670        let is_edge_chunk = self.is_edge_chunk();
671        match presentation.display_mode {
672            GeometrySceneDisplayMode::Wireframe if !is_edge_chunk => {
673                render_data.material.alpha_mode = AlphaMode::Blend;
674                render_data.material.albedo.w = presentation
675                    .display_mode
676                    .alpha(presentation.edge_overlay_enabled);
677            }
678            GeometrySceneDisplayMode::Wireframe => {}
679            GeometrySceneDisplayMode::Edges
680                if is_edge_chunk && !presentation.edge_overlay_enabled =>
681            {
682                for vertex in &mut render_data.vertices {
683                    vertex.color[3] = 0.0;
684                }
685            }
686            GeometrySceneDisplayMode::Edges | GeometrySceneDisplayMode::Shaded => {
687                if !is_edge_chunk {
688                    let alpha = presentation
689                        .display_mode
690                        .alpha(presentation.edge_overlay_enabled)
691                        .min(render_data.material.albedo.w);
692                    render_data.material.albedo.w = alpha;
693                    render_data.material.alpha_mode = if alpha < 0.98 {
694                        AlphaMode::Blend
695                    } else {
696                        render_data.material.alpha_mode
697                    };
698                    for vertex in &mut render_data.vertices {
699                        vertex.color[3] = vertex.color[3].min(alpha);
700                    }
701                }
702            }
703        }
704
705        for highlight in &presentation.region_highlights {
706            self.apply_region_color(&mut render_data, &highlight.region_id, highlight.color);
707        }
708        if let Some(region_id) = presentation.hovered_region_id.as_deref() {
709            self.apply_region_color(&mut render_data, region_id, [0.43, 0.78, 1.0, 1.0]);
710        }
711        if let Some(region_id) = presentation.selected_region_id.as_deref() {
712            self.apply_region_color(&mut render_data, region_id, [0.98, 0.78, 0.22, 1.0]);
713        }
714        render_data
715    }
716
717    fn is_edge_chunk(&self) -> bool {
718        self.render_data.pipeline_type == PipelineType::Lines
719            || self.chunk_id.contains(":edges")
720            || self
721                .label
722                .as_ref()
723                .map(|label| label.to_ascii_lowercase().contains("edge"))
724                .unwrap_or(false)
725    }
726
727    fn region_for_triangle(&self, triangle_index: u32) -> Option<&GeometrySceneRegion> {
728        self.regions.iter().find(|region| {
729            region.triangle_ranges.iter().any(|range| {
730                triangle_index >= range.start
731                    && triangle_index < range.start.saturating_add(range.count)
732            })
733        })
734    }
735
736    fn region_anchor(&self, region_id: &str) -> Option<Vec3> {
737        if self.render_data.pipeline_type != PipelineType::Triangles {
738            return None;
739        }
740        let region = self
741            .regions
742            .iter()
743            .find(|item| item.region_id == region_id)?;
744        let mut weighted_centroid = Vec3::ZERO;
745        let mut total_area = 0.0_f32;
746        let mut fallback_centroid = Vec3::ZERO;
747        let mut fallback_count = 0_usize;
748
749        for range in &region.triangle_ranges {
750            let start = range.start as usize;
751            let end = start.saturating_add(range.count as usize);
752            for triangle_index in start..end {
753                let Some((a, b, c)) = self.triangle_vertices(triangle_index) else {
754                    continue;
755                };
756                let centroid = (a + b + c) / 3.0;
757                let area = (b - a).cross(c - a).length() * 0.5;
758                if area.is_finite() && area > 1.0e-8 {
759                    weighted_centroid += centroid * area;
760                    total_area += area;
761                } else {
762                    fallback_centroid += centroid;
763                    fallback_count += 1;
764                }
765            }
766        }
767
768        if total_area > 0.0 {
769            Some(weighted_centroid / total_area)
770        } else if fallback_count > 0 {
771            Some(fallback_centroid / fallback_count as f32)
772        } else {
773            None
774        }
775    }
776
777    fn triangle_vertices(&self, triangle_index: usize) -> Option<(Vec3, Vec3, Vec3)> {
778        let vertex_at = |index: u32| {
779            self.render_data
780                .vertices
781                .get(index as usize)
782                .map(|vertex| Vec3::from_array(vertex.position))
783        };
784
785        if let Some(indices) = self.indices.as_ref() {
786            let base = triangle_index.checked_mul(3)?;
787            let triangle = indices.get(base..base + 3)?;
788            Some((
789                vertex_at(triangle[0])?,
790                vertex_at(triangle[1])?,
791                vertex_at(triangle[2])?,
792            ))
793        } else {
794            let base = triangle_index.checked_mul(3)?;
795            let a = self.render_data.vertices.get(base)?;
796            let b = self.render_data.vertices.get(base + 1)?;
797            let c = self.render_data.vertices.get(base + 2)?;
798            Some((
799                Vec3::from_array(a.position),
800                Vec3::from_array(b.position),
801                Vec3::from_array(c.position),
802            ))
803        }
804    }
805
806    fn apply_region_color(&self, render_data: &mut RenderData, region_id: &str, color: [f32; 4]) {
807        if self.render_data.pipeline_type != PipelineType::Triangles {
808            return;
809        }
810        let Some(region) = self.regions.iter().find(|item| item.region_id == region_id) else {
811            return;
812        };
813        let Some(indices) = self.indices.as_ref() else {
814            return;
815        };
816        let Some(render_indices) = render_data.indices.as_mut() else {
817            return;
818        };
819        for range in &region.triangle_ranges {
820            let start = range.start as usize;
821            let end = start.saturating_add(range.count as usize);
822            for triangle_index in start..end {
823                let base = triangle_index.saturating_mul(3);
824                let Some(triangle) = indices.get(base..base + 3) else {
825                    continue;
826                };
827                if render_indices.get(base..base + 3).is_none() {
828                    continue;
829                }
830                let mut isolated_indices = [0_u32; 3];
831                let mut isolated = true;
832                for (slot, vertex_index) in triangle.iter().copied().enumerate() {
833                    let Some(vertex) = render_data.vertices.get(vertex_index as usize).copied()
834                    else {
835                        isolated = false;
836                        break;
837                    };
838                    let mut vertex = vertex;
839                    vertex.color = color;
840                    let next_index = render_data.vertices.len();
841                    if next_index > u32::MAX as usize {
842                        isolated = false;
843                        break;
844                    }
845                    render_data.vertices.push(vertex);
846                    isolated_indices[slot] = next_index as u32;
847                }
848                if isolated {
849                    render_indices[base..base + 3].copy_from_slice(&isolated_indices);
850                }
851            }
852        }
853    }
854}
855
856#[derive(Debug, Clone, PartialEq, Eq)]
857pub struct GeometrySceneRegion {
858    pub region_id: String,
859    pub label: Option<String>,
860    pub tag: Option<String>,
861    pub triangle_ranges: Vec<GeometrySceneTriangleRange>,
862}
863
864impl GeometrySceneRegion {
865    pub fn new(
866        region_id: impl Into<String>,
867        label: Option<String>,
868        tag: Option<String>,
869        triangle_ranges: Vec<GeometrySceneTriangleRange>,
870    ) -> Self {
871        Self {
872            region_id: region_id.into(),
873            label,
874            tag,
875            triangle_ranges,
876        }
877    }
878}
879
880#[derive(Debug, Clone, Copy, PartialEq, Eq)]
881pub struct GeometrySceneTriangleRange {
882    pub start: u32,
883    pub count: u32,
884}
885
886impl GeometrySceneTriangleRange {
887    pub fn new(start: u32, count: u32) -> Self {
888        Self { start, count }
889    }
890}
891
892pub fn cad_default_material() -> Material {
893    Material {
894        albedo: Vec4::new(0.46, 0.49, 0.48, 1.0),
895        roughness: 0.72,
896        metallic: 0.0,
897        emissive: Vec4::ZERO,
898        alpha_mode: AlphaMode::Opaque,
899        double_sided: true,
900    }
901}
902
903pub fn vertex(position: [f32; 3], color: [f32; 4], normal: [f32; 3]) -> Vertex {
904    Vertex {
905        position,
906        color,
907        normal,
908        tex_coords: [0.0, 0.0],
909    }
910}
911
912fn bounds_from_vertices(vertices: &[Vertex]) -> BoundingBox {
913    if vertices.is_empty() {
914        return BoundingBox::default();
915    }
916    let mut bounds = BoundingBox::new(
917        Vec3::from_array(vertices[0].position),
918        Vec3::from_array(vertices[0].position),
919    );
920    for item in vertices.iter().skip(1) {
921        bounds.expand(Vec3::from_array(item.position));
922    }
923    bounds
924}
925
926fn combined_chunk_bounds(chunks: &[GeometrySceneChunk]) -> BoundingBox {
927    let mut bounds = BoundingBox::default();
928    for chunk in chunks {
929        bounds.expand_by_box(&chunk.bounds);
930    }
931    bounds
932}
933
934fn annotation_arrow_length(bounds: BoundingBox) -> f32 {
935    let size = bounds.size();
936    let diagonal = size.length();
937    if diagonal.is_finite() && diagonal > 1.0e-6 {
938        diagonal * 0.075
939    } else {
940        1.0
941    }
942}
943
944fn normalized_annotation_direction(direction: [f32; 3]) -> Option<Vec3> {
945    let direction = Vec3::from_array(direction);
946    let length = direction.length();
947    (length.is_finite() && length > 1.0e-8).then_some(direction / length)
948}
949
950fn append_annotation_arrow(
951    vertices: &mut Vec<Vertex>,
952    anchor: Vec3,
953    direction: Vec3,
954    length: f32,
955    color: [f32; 4],
956) {
957    let start = anchor;
958    let end = anchor + direction * length;
959    append_annotation_line(vertices, start, end, color);
960
961    let side = perpendicular_unit(direction);
962    let wing_base = end - direction * (length * 0.28);
963    let wing_size = length * 0.12;
964    append_annotation_line(vertices, end, wing_base + side * wing_size, color);
965    append_annotation_line(vertices, end, wing_base - side * wing_size, color);
966}
967
968fn append_annotation_line(vertices: &mut Vec<Vertex>, start: Vec3, end: Vec3, color: [f32; 4]) {
969    vertices.push(vertex(start.to_array(), color, [0.0, 0.0, 1.0]));
970    vertices.push(vertex(end.to_array(), color, [0.0, 0.0, 1.0]));
971}
972
973fn perpendicular_unit(direction: Vec3) -> Vec3 {
974    let reference = if direction.z.abs() < 0.9 {
975        Vec3::Z
976    } else {
977        Vec3::Y
978    };
979    let side = direction.cross(reference);
980    let length = side.length();
981    if length > 1.0e-8 {
982        side / length
983    } else {
984        Vec3::X
985    }
986}
987
988fn stable_node_id(scene_id: &str, revision: u64, index: usize, chunk_id: &str) -> u64 {
989    const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
990    const FNV_PRIME: u64 = 0x100000001b3;
991    let mut hash = FNV_OFFSET_BASIS;
992    for byte in scene_id
993        .as_bytes()
994        .iter()
995        .chain(chunk_id.as_bytes())
996        .copied()
997    {
998        hash ^= u64::from(byte);
999        hash = hash.wrapping_mul(FNV_PRIME);
1000    }
1001    hash ^= revision;
1002    hash = hash.wrapping_mul(FNV_PRIME);
1003    hash ^ index as u64
1004}
1005
1006#[derive(Debug, Clone)]
1007struct IndexedTriangle {
1008    a: Vec3,
1009    b: Vec3,
1010    c: Vec3,
1011    bounds: BoundingBox,
1012    centroid: Vec3,
1013    mesh_id: Option<String>,
1014    chunk_id: String,
1015    triangle_index: usize,
1016    region_id: Option<String>,
1017    region_label: Option<String>,
1018    region_tag: Option<String>,
1019}
1020
1021#[derive(Debug, Clone)]
1022struct PickBvhNode {
1023    bounds: BoundingBox,
1024    kind: PickBvhNodeKind,
1025}
1026
1027#[derive(Debug, Clone, Copy)]
1028enum PickBvhNodeKind {
1029    Leaf { start: usize, end: usize },
1030    Branch { left: usize, right: usize },
1031}
1032
1033#[derive(Debug, Clone, Copy)]
1034struct PickRay {
1035    origin: Vec3,
1036    direction: Vec3,
1037}
1038
1039#[derive(Debug, Clone, Copy)]
1040struct PickHit {
1041    triangle_index: usize,
1042    distance: f32,
1043}
1044
1045fn build_pick_bvh_node(
1046    nodes: &mut Vec<PickBvhNode>,
1047    triangles: &mut [IndexedTriangle],
1048    start: usize,
1049    end: usize,
1050) -> Option<usize> {
1051    if start >= end {
1052        return None;
1053    }
1054    let bounds = combined_triangle_bounds(&triangles[start..end]);
1055    let node_index = nodes.len();
1056    nodes.push(PickBvhNode {
1057        bounds,
1058        kind: PickBvhNodeKind::Leaf { start, end },
1059    });
1060    const LEAF_TRIANGLES: usize = 32;
1061    if end - start <= LEAF_TRIANGLES {
1062        return Some(node_index);
1063    }
1064    let centroid_bounds = combined_centroid_bounds(&triangles[start..end]);
1065    let extent = centroid_bounds.max - centroid_bounds.min;
1066    let axis = if extent.x >= extent.y && extent.x >= extent.z {
1067        0
1068    } else if extent.y >= extent.z {
1069        1
1070    } else {
1071        2
1072    };
1073    triangles[start..end].sort_by(|a, b| {
1074        a.centroid[axis]
1075            .partial_cmp(&b.centroid[axis])
1076            .unwrap_or(std::cmp::Ordering::Equal)
1077    });
1078    let mid = start + (end - start) / 2;
1079    let left = build_pick_bvh_node(nodes, triangles, start, mid);
1080    let right = build_pick_bvh_node(nodes, triangles, mid, end);
1081    if let (Some(left), Some(right)) = (left, right) {
1082        nodes[node_index].kind = PickBvhNodeKind::Branch { left, right };
1083    }
1084    Some(node_index)
1085}
1086
1087fn triangle_bounds(a: Vec3, b: Vec3, c: Vec3) -> BoundingBox {
1088    let mut bounds = BoundingBox::new(a, a);
1089    bounds.expand(b);
1090    bounds.expand(c);
1091    bounds
1092}
1093
1094fn combined_triangle_bounds(triangles: &[IndexedTriangle]) -> BoundingBox {
1095    let mut bounds = BoundingBox::default();
1096    for triangle in triangles {
1097        bounds.expand_by_box(&triangle.bounds);
1098    }
1099    bounds
1100}
1101
1102fn combined_centroid_bounds(triangles: &[IndexedTriangle]) -> BoundingBox {
1103    let Some(first) = triangles.first() else {
1104        return BoundingBox::default();
1105    };
1106    let mut bounds = BoundingBox::new(first.centroid, first.centroid);
1107    for triangle in triangles.iter().skip(1) {
1108        bounds.expand(triangle.centroid);
1109    }
1110    bounds
1111}
1112
1113fn ray_intersects_bounds(ray: &PickRay, bounds: BoundingBox) -> Option<f32> {
1114    let mut t_min: f32 = 0.0;
1115    let mut t_max = f32::INFINITY;
1116    for axis in 0..3 {
1117        let origin = ray.origin[axis];
1118        let direction = ray.direction[axis];
1119        let min = bounds.min[axis];
1120        let max = bounds.max[axis];
1121        if direction.abs() < 1e-8 {
1122            if origin < min || origin > max {
1123                return None;
1124            }
1125            continue;
1126        }
1127        let inv_direction = 1.0 / direction;
1128        let mut t0 = (min - origin) * inv_direction;
1129        let mut t1 = (max - origin) * inv_direction;
1130        if t0 > t1 {
1131            std::mem::swap(&mut t0, &mut t1);
1132        }
1133        t_min = t_min.max(t0);
1134        t_max = t_max.min(t1);
1135        if t_max < t_min {
1136            return None;
1137        }
1138    }
1139    Some(t_min.max(0.0))
1140}
1141
1142fn ray_intersects_triangle(ray: &PickRay, a: Vec3, b: Vec3, c: Vec3) -> Option<f32> {
1143    let edge1 = b - a;
1144    let edge2 = c - a;
1145    let pvec = ray.direction.cross(edge2);
1146    let det = edge1.dot(pvec);
1147    if det.abs() < 1e-7 {
1148        return None;
1149    }
1150    let inv_det = 1.0 / det;
1151    let tvec = ray.origin - a;
1152    let u = tvec.dot(pvec) * inv_det;
1153    if !(0.0..=1.0).contains(&u) {
1154        return None;
1155    }
1156    let qvec = tvec.cross(edge1);
1157    let v = ray.direction.dot(qvec) * inv_det;
1158    if v < 0.0 || u + v > 1.0 {
1159        return None;
1160    }
1161    let t = edge2.dot(qvec) * inv_det;
1162    (t > 1e-6).then_some(t)
1163}
1164
1165#[cfg(test)]
1166mod tests {
1167    use super::*;
1168    use crate::core::ProjectionType;
1169
1170    #[test]
1171    fn pick_index_returns_region_for_triangle() {
1172        let material = cad_default_material();
1173        let chunk = GeometrySceneChunk::indexed_triangles(
1174            "face_chunk",
1175            vec![
1176                vertex([-1.0, -1.0, 0.0], [0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 1.0]),
1177                vertex([1.0, -1.0, 0.0], [0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 1.0]),
1178                vertex([0.0, 1.0, 0.0], [0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 1.0]),
1179            ],
1180            vec![0, 1, 2],
1181            material,
1182        )
1183        .with_regions(vec![GeometrySceneRegion::new(
1184            "face_a",
1185            Some("Face A".to_string()),
1186            Some("cad-face".to_string()),
1187            vec![GeometrySceneTriangleRange::new(0, 1)],
1188        )]);
1189        let scene = GeometryScene::new("scene", 1, vec![chunk]);
1190        let index = GeometryScenePickIndex::build(&scene);
1191        let mut camera = Camera::new();
1192        camera.position = Vec3::new(0.0, 0.0, 5.0);
1193        camera.target = Vec3::ZERO;
1194        camera.up = Vec3::Y;
1195        camera.aspect_ratio = 1.0;
1196        camera.projection = ProjectionType::Perspective {
1197            fov: 45.0_f32.to_radians(),
1198            near: 0.1,
1199            far: 100.0,
1200        };
1201        camera.mark_dirty();
1202
1203        let hit = index.pick(GeometryScenePickRequest {
1204            camera,
1205            surface_size: [800.0, 800.0],
1206            position: [400.0, 400.0],
1207        });
1208        assert_eq!(
1209            hit.and_then(|hit| hit.region_id),
1210            Some("face_a".to_string())
1211        );
1212    }
1213
1214    #[test]
1215    fn presentation_region_annotations_emit_marker_and_vector_nodes() {
1216        let material = cad_default_material();
1217        let chunk = GeometrySceneChunk::indexed_triangles(
1218            "face_chunk",
1219            vec![
1220                vertex([-1.0, -1.0, 0.0], [0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 1.0]),
1221                vertex([1.0, -1.0, 0.0], [0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 1.0]),
1222                vertex([0.0, 1.0, 0.0], [0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 1.0]),
1223            ],
1224            vec![0, 1, 2],
1225            material,
1226        )
1227        .with_regions(vec![GeometrySceneRegion::new(
1228            "loaded_face",
1229            Some("Loaded face".to_string()),
1230            Some("cad-face".to_string()),
1231            vec![GeometrySceneTriangleRange::new(0, 1)],
1232        )]);
1233        let scene = GeometryScene::new("scene", 1, vec![chunk]);
1234        let nodes = scene.nodes_with_presentation(&GeometryScenePresentation {
1235            region_annotations: vec![GeometrySceneRegionAnnotation {
1236                region_id: "loaded_face".to_string(),
1237                color: [0.9, 0.1, 0.1, 1.0],
1238                role: Some("load".to_string()),
1239                label: Some("load".to_string()),
1240                direction: Some([0.0, 0.0, 1.0]),
1241                size: Some(18.0),
1242            }],
1243            ..Default::default()
1244        });
1245
1246        let marker = nodes
1247            .iter()
1248            .find(|node| node.name == "FEA region markers")
1249            .and_then(|node| node.render_data.as_ref())
1250            .expect("marker annotation node");
1251        assert_eq!(marker.pipeline_type, PipelineType::Points);
1252        assert_eq!(marker.vertices.len(), 1);
1253        assert!((marker.vertices[0].position[1] - (-1.0 / 3.0)).abs() < 1.0e-6);
1254        assert_eq!(marker.vertices[0].normal[2], 18.0);
1255
1256        let vector = nodes
1257            .iter()
1258            .find(|node| node.name == "FEA load vectors")
1259            .and_then(|node| node.render_data.as_ref())
1260            .expect("vector annotation node");
1261        assert_eq!(vector.pipeline_type, PipelineType::Lines);
1262        assert_eq!(vector.vertices.len(), 6);
1263        assert!(vector.vertices[1].position[2] > vector.vertices[0].position[2]);
1264    }
1265}