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, Deserializer, Serialize};
12use std::collections::BTreeSet;
13
14const SECTION_DISTANCE_EPSILON: f32 = 1.0e-6;
15const GEOMETRY_SELECTED_REGION_COLOR: [f32; 4] = [0.98, 0.45, 0.12, 1.0];
16const GEOMETRY_HOVER_REGION_COLOR: [f32; 4] = [0.43, 0.78, 1.0, 1.0];
17const GEOMETRY_MATERIAL_REGION_COLOR: [f32; 4] = [0.38, 0.58, 0.96, 0.92];
18const GEOMETRY_BOUNDARY_REGION_COLOR: [f32; 4] = [0.22, 0.82, 0.52, 0.96];
19const GEOMETRY_DRIVING_REGION_COLOR: [f32; 4] = [0.98, 0.38, 0.28, 0.96];
20
21#[derive(Debug, Clone)]
22pub struct GeometryScene {
23    pub scene_id: String,
24    pub revision: u64,
25    pub title: Option<String>,
26    pub overlay: Option<GeometrySceneOverlay>,
27    pub chunks: Vec<GeometrySceneChunk>,
28    pub bounds: BoundingBox,
29    pub show_grid: bool,
30    pub axis_equal: bool,
31}
32
33impl GeometryScene {
34    pub fn new(
35        scene_id: impl Into<String>,
36        revision: u64,
37        chunks: Vec<GeometrySceneChunk>,
38    ) -> Self {
39        let bounds = combined_chunk_bounds(&chunks);
40        Self {
41            scene_id: scene_id.into(),
42            revision,
43            title: None,
44            overlay: None,
45            chunks,
46            bounds,
47            show_grid: false,
48            axis_equal: true,
49        }
50    }
51
52    pub fn with_title(mut self, title: impl Into<String>) -> Self {
53        self.title = Some(title.into());
54        self
55    }
56
57    pub fn with_overlay(mut self, overlay: GeometrySceneOverlay) -> Self {
58        self.overlay = Some(overlay);
59        self
60    }
61
62    pub fn append_chunks(&mut self, chunks: impl IntoIterator<Item = GeometrySceneChunk>) {
63        self.chunks.extend(chunks);
64        self.revision = self.revision.saturating_add(1);
65        self.bounds = combined_chunk_bounds(&self.chunks);
66    }
67
68    pub fn set_overlay(&mut self, overlay: GeometrySceneOverlay) {
69        self.overlay = Some(overlay);
70        self.revision = self.revision.saturating_add(1);
71    }
72
73    pub fn cache_key(&self) -> GeometrySceneCacheKey {
74        GeometrySceneCacheKey {
75            scene_id: self.scene_id.clone(),
76            revision: self.revision,
77            chunk_count: self.chunks.len(),
78            vertex_count: self.vertex_count(),
79            index_count: self.index_count(),
80        }
81    }
82
83    pub fn vertex_count(&self) -> usize {
84        self.chunks
85            .iter()
86            .map(|chunk| chunk.render_data.vertex_count())
87            .sum()
88    }
89
90    pub fn index_count(&self) -> usize {
91        self.chunks
92            .iter()
93            .map(|chunk| chunk.indices.as_ref().map(Vec::len).unwrap_or(0))
94            .sum()
95    }
96
97    pub fn triangle_count(&self) -> usize {
98        self.chunks
99            .iter()
100            .map(GeometrySceneChunk::triangle_count)
101            .sum()
102    }
103
104    pub fn is_empty(&self) -> bool {
105        self.chunks.is_empty()
106    }
107
108    pub fn nodes(&self) -> Vec<SceneNode> {
109        self.nodes_with_presentation(&GeometryScenePresentation::default())
110    }
111
112    pub fn nodes_with_presentation(
113        &self,
114        presentation: &GeometryScenePresentation,
115    ) -> Vec<SceneNode> {
116        let mut nodes: Vec<SceneNode> = self
117            .chunks
118            .iter()
119            .enumerate()
120            .map(|(index, chunk)| SceneNode {
121                id: self.chunk_node_id(index, &chunk.chunk_id),
122                name: chunk
123                    .label
124                    .clone()
125                    .unwrap_or_else(|| format!("Geometry chunk {}", index + 1)),
126                transform: Mat4::IDENTITY,
127                visible: chunk.visible,
128                cast_shadows: false,
129                receive_shadows: false,
130                axes_index: 0,
131                parent: None,
132                children: Vec::new(),
133                render_data: Some(chunk.render_data_with_presentation(presentation)),
134                bounds: chunk.bounds,
135                lod_levels: Vec::new(),
136                current_lod: 0,
137            })
138            .collect();
139        nodes.extend(self.annotation_nodes(presentation));
140        nodes
141    }
142
143    pub fn chunk_node_id(&self, index: usize, chunk_id: &str) -> u64 {
144        stable_node_id(&self.scene_id, self.revision, index, chunk_id)
145    }
146
147    fn annotation_nodes(&self, presentation: &GeometryScenePresentation) -> Vec<SceneNode> {
148        if presentation.region_annotations.is_empty() {
149            return Vec::new();
150        }
151
152        let mut point_vertices = Vec::new();
153        let mut line_vertices = Vec::new();
154        let arrow_length = annotation_arrow_length(self.bounds);
155
156        for annotation in &presentation.region_annotations {
157            for chunk in &self.chunks {
158                if !chunk.visible || chunk.render_data.pipeline_type != PipelineType::Triangles {
159                    continue;
160                }
161                let Some(anchor) = chunk.region_anchor(&annotation.region_id) else {
162                    continue;
163                };
164                let color = annotation
165                    .color
166                    .unwrap_or_else(|| geometry_region_role_color(annotation.role.as_deref()));
167                let mut marker = vertex(
168                    anchor.to_array(),
169                    color,
170                    [0.0, 0.0, annotation.size.unwrap_or(15.0)],
171                );
172                marker.tex_coords = [1.0, 1.0];
173                point_vertices.push(marker);
174
175                if let Some(direction) = annotation
176                    .direction
177                    .and_then(normalized_annotation_direction)
178                {
179                    append_annotation_arrow(
180                        &mut line_vertices,
181                        anchor,
182                        direction,
183                        arrow_length,
184                        color,
185                    );
186                }
187            }
188        }
189
190        let mut nodes = Vec::new();
191        if !point_vertices.is_empty() {
192            nodes.push(self.annotation_node(
193                "FEA region markers",
194                "__fea_annotations:markers",
195                self.chunks.len(),
196                PipelineType::Points,
197                point_vertices,
198            ));
199        }
200        if !line_vertices.is_empty() {
201            nodes.push(self.annotation_node(
202                "FEA load vectors",
203                "__fea_annotations:vectors",
204                self.chunks.len() + 1,
205                PipelineType::Lines,
206                line_vertices,
207            ));
208        }
209        nodes
210    }
211
212    fn annotation_node(
213        &self,
214        name: &str,
215        chunk_id: &str,
216        index: usize,
217        pipeline_type: PipelineType,
218        vertices: Vec<Vertex>,
219    ) -> SceneNode {
220        let vertex_count = vertices.len();
221        let bounds = bounds_from_vertices(&vertices);
222        SceneNode {
223            id: stable_node_id(&self.scene_id, self.revision, index, chunk_id),
224            name: name.to_string(),
225            transform: Mat4::IDENTITY,
226            visible: true,
227            cast_shadows: false,
228            receive_shadows: false,
229            axes_index: 0,
230            parent: None,
231            children: Vec::new(),
232            render_data: Some(RenderData {
233                pipeline_type,
234                vertices,
235                indices: None,
236                gpu_vertices: None,
237                bounds: Some(bounds),
238                material: Material {
239                    albedo: Vec4::ONE,
240                    alpha_mode: AlphaMode::Blend,
241                    double_sided: true,
242                    ..Default::default()
243                },
244                draw_calls: vec![DrawCall {
245                    vertex_offset: 0,
246                    vertex_count,
247                    index_offset: None,
248                    index_count: None,
249                    instance_count: 1,
250                }],
251                image: None,
252            }),
253            bounds,
254            lod_levels: Vec::new(),
255            current_lod: 0,
256        }
257    }
258}
259
260#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
261#[serde(rename_all = "camelCase")]
262pub struct GeometryScenePresentation {
263    pub selected_region_id: Option<String>,
264    #[serde(default)]
265    pub selected_region_ids: Vec<String>,
266    pub hovered_region_id: Option<String>,
267    #[serde(default)]
268    pub region_highlights: Vec<GeometrySceneRegionHighlight>,
269    #[serde(default)]
270    pub region_annotations: Vec<GeometrySceneRegionAnnotation>,
271    #[serde(default)]
272    pub display_mode: GeometrySceneDisplayMode,
273    #[serde(default = "default_edge_overlay_enabled")]
274    pub edge_overlay_enabled: bool,
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub hidden_owner_node_ids: Option<Vec<String>>,
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub isolated_owner_node_ids: Option<Vec<String>>,
279    #[serde(
280        default,
281        deserialize_with = "deserialize_explicit_optional_section",
282        skip_serializing_if = "Option::is_none"
283    )]
284    pub section: Option<Option<GeometrySceneSection>>,
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub view_preset: Option<GeometrySceneViewPreset>,
287}
288
289impl Default for GeometryScenePresentation {
290    fn default() -> Self {
291        Self {
292            selected_region_id: None,
293            selected_region_ids: Vec::new(),
294            hovered_region_id: None,
295            region_highlights: Vec::new(),
296            region_annotations: Vec::new(),
297            display_mode: GeometrySceneDisplayMode::Shaded,
298            edge_overlay_enabled: true,
299            hidden_owner_node_ids: None,
300            isolated_owner_node_ids: None,
301            section: None,
302            view_preset: None,
303        }
304    }
305}
306
307impl GeometryScenePresentation {
308    pub(crate) fn rewrites_geometry_vertices(&self) -> bool {
309        self.hovered_region_id.is_some()
310            || self.selected_region_id.is_some()
311            || !self.selected_region_ids.is_empty()
312            || !self.region_highlights.is_empty()
313            || self.active_section().is_some()
314    }
315
316    pub(crate) fn resolves_owner_visibility(&self) -> bool {
317        self.hidden_owner_node_ids.is_some() || self.isolated_owner_node_ids.is_some()
318    }
319
320    pub(crate) fn resolved_hidden_owner_node_ids<'a, I>(
321        &self,
322        all_owner_node_ids: I,
323        current_hidden_owner_node_ids: &BTreeSet<String>,
324    ) -> BTreeSet<String>
325    where
326        I: IntoIterator<Item = &'a str>,
327    {
328        if !self.resolves_owner_visibility() {
329            return current_hidden_owner_node_ids.clone();
330        }
331
332        let mut hidden = BTreeSet::new();
333        if let Some(isolated_owner_node_ids) = self.isolated_owner_node_ids.as_ref() {
334            let isolated =
335                normalized_owner_node_id_set(isolated_owner_node_ids.iter().map(String::as_str));
336            for owner_id in all_owner_node_ids {
337                let normalized = owner_id.trim();
338                if !normalized.is_empty() && !isolated.contains(normalized) {
339                    hidden.insert(normalized.to_string());
340                }
341            }
342        }
343
344        if let Some(hidden_owner_node_ids) = self.hidden_owner_node_ids.as_ref() {
345            hidden.extend(normalized_owner_node_id_set(
346                hidden_owner_node_ids.iter().map(String::as_str),
347            ));
348        }
349        hidden
350    }
351
352    pub(crate) fn resolves_section(&self) -> bool {
353        self.section.is_some()
354    }
355
356    pub(crate) fn active_section(&self) -> Option<&GeometrySceneSection> {
357        self.section.as_ref().and_then(Option::as_ref)
358    }
359}
360
361fn normalized_owner_node_id_set<'a, I>(ids: I) -> BTreeSet<String>
362where
363    I: IntoIterator<Item = &'a str>,
364{
365    ids.into_iter()
366        .map(str::trim)
367        .filter(|id| !id.is_empty())
368        .map(ToOwned::to_owned)
369        .collect()
370}
371
372fn deserialize_explicit_optional_section<'de, D>(
373    deserializer: D,
374) -> Result<Option<Option<GeometrySceneSection>>, D::Error>
375where
376    D: Deserializer<'de>,
377{
378    Option::<GeometrySceneSection>::deserialize(deserializer).map(Some)
379}
380
381#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
382#[serde(rename_all = "camelCase")]
383pub struct GeometrySceneSection {
384    pub plane: GeometrySceneSectionPlane,
385}
386
387#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
388#[serde(rename_all = "camelCase")]
389pub struct GeometrySceneSectionPlane {
390    pub normal: [f32; 3],
391    #[serde(default)]
392    pub origin: Option<[f32; 3]>,
393    #[serde(default)]
394    pub offset: Option<f32>,
395    #[serde(default)]
396    pub label: Option<String>,
397}
398
399#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
400#[serde(rename_all = "snake_case")]
401pub enum GeometrySceneViewPreset {
402    Perspective,
403    Isometric,
404    Front,
405    Back,
406    Left,
407    Right,
408    Top,
409    Bottom,
410}
411
412#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
413#[serde(rename_all = "camelCase")]
414pub struct GeometrySceneRegionHighlight {
415    pub region_id: String,
416    #[serde(default)]
417    pub color: Option<[f32; 4]>,
418    #[serde(default)]
419    pub role: Option<String>,
420    #[serde(default)]
421    pub label: Option<String>,
422}
423
424#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
425#[serde(rename_all = "camelCase")]
426pub struct GeometrySceneRegionAnnotation {
427    pub region_id: String,
428    #[serde(default)]
429    pub color: Option<[f32; 4]>,
430    #[serde(default)]
431    pub role: Option<String>,
432    #[serde(default)]
433    pub label: Option<String>,
434    #[serde(default)]
435    pub direction: Option<[f32; 3]>,
436    #[serde(default)]
437    pub size: Option<f32>,
438}
439
440#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
441#[serde(rename_all = "camelCase")]
442pub enum GeometrySceneDisplayMode {
443    Shaded,
444    Edges,
445    Wireframe,
446}
447
448impl Default for GeometrySceneDisplayMode {
449    fn default() -> Self {
450        Self::Shaded
451    }
452}
453
454impl GeometrySceneDisplayMode {
455    fn alpha(self, edge_overlay_enabled: bool) -> f32 {
456        match self {
457            Self::Shaded => 1.0,
458            Self::Edges if edge_overlay_enabled => 0.84,
459            Self::Edges => 0.94,
460            Self::Wireframe => 0.16,
461        }
462    }
463}
464
465#[derive(Debug, Clone)]
466pub struct GeometryScenePickRequest {
467    pub camera: Camera,
468    pub surface_size: [f32; 2],
469    pub position: [f32; 2],
470}
471
472#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
473#[serde(rename_all = "camelCase")]
474pub struct GeometryScenePickResult {
475    pub mesh_id: Option<String>,
476    pub chunk_id: String,
477    pub triangle_index: usize,
478    pub region_id: Option<String>,
479    pub region_label: Option<String>,
480    pub region_tag: Option<String>,
481    pub distance: f32,
482    pub position: [f32; 3],
483}
484
485fn default_edge_overlay_enabled() -> bool {
486    true
487}
488
489#[derive(Debug, Clone)]
490pub struct GeometryScenePickIndex {
491    scene_key: GeometrySceneCacheKey,
492    triangles: Vec<IndexedTriangle>,
493    nodes: Vec<PickBvhNode>,
494    root: Option<usize>,
495}
496
497impl GeometryScenePickIndex {
498    pub fn build(scene: &GeometryScene) -> Self {
499        let mut triangles = Vec::with_capacity(scene.triangle_count());
500        for chunk in &scene.chunks {
501            if !chunk.visible || chunk.render_data.pipeline_type != PipelineType::Triangles {
502                continue;
503            }
504            let Some(indices) = chunk.indices.as_ref() else {
505                continue;
506            };
507            for (triangle_index, triangle) in indices.chunks_exact(3).enumerate() {
508                let a = chunk.vertices.get(triangle[0] as usize);
509                let b = chunk.vertices.get(triangle[1] as usize);
510                let c = chunk.vertices.get(triangle[2] as usize);
511                let (Some(a), Some(b), Some(c)) = (a, b, c) else {
512                    continue;
513                };
514                let a = Vec3::from_array(a.position);
515                let b = Vec3::from_array(b.position);
516                let c = Vec3::from_array(c.position);
517                let bounds = triangle_bounds(a, b, c);
518                let region = chunk.region_for_triangle(triangle_index as u32);
519                triangles.push(IndexedTriangle {
520                    a,
521                    b,
522                    c,
523                    bounds,
524                    centroid: (a + b + c) / 3.0,
525                    mesh_id: chunk.mesh_id.clone(),
526                    chunk_id: chunk.chunk_id.clone(),
527                    triangle_index,
528                    region_id: region.map(|item| item.region_id.clone()),
529                    region_label: region.and_then(|item| item.label.clone()),
530                    region_tag: region.and_then(|item| item.tag.clone()),
531                });
532            }
533        }
534
535        let mut nodes = Vec::new();
536        let triangle_count = triangles.len();
537        let root = build_pick_bvh_node(&mut nodes, &mut triangles, 0, triangle_count);
538        Self {
539            scene_key: scene.cache_key(),
540            triangles,
541            nodes,
542            root,
543        }
544    }
545
546    pub fn scene_key(&self) -> &GeometrySceneCacheKey {
547        &self.scene_key
548    }
549
550    pub fn triangle_count(&self) -> usize {
551        self.triangles.len()
552    }
553
554    pub fn is_empty(&self) -> bool {
555        self.triangles.is_empty()
556    }
557
558    pub fn pick(&self, request: GeometryScenePickRequest) -> Option<GeometryScenePickResult> {
559        if request.surface_size[0] <= 0.0 || request.surface_size[1] <= 0.0 {
560            return None;
561        }
562        let mut camera = request.camera;
563        let screen_size = Vec2::new(request.surface_size[0], request.surface_size[1]);
564        let screen_pos = Vec2::new(request.position[0], request.position[1]);
565        let origin = camera.screen_to_world(screen_pos, screen_size, 0.0);
566        let far = camera.screen_to_world(screen_pos, screen_size, 1.0);
567        let direction = (far - origin).normalize_or_zero();
568        if direction.length_squared() <= f32::EPSILON {
569            return None;
570        }
571        let ray = PickRay { origin, direction };
572        let mut best: Option<PickHit> = None;
573        if let Some(root) = self.root {
574            self.pick_node(root, &ray, &mut best);
575        }
576        let hit = best?;
577        let triangle = self.triangles.get(hit.triangle_index)?;
578        Some(GeometryScenePickResult {
579            mesh_id: triangle.mesh_id.clone(),
580            chunk_id: triangle.chunk_id.clone(),
581            triangle_index: triangle.triangle_index,
582            region_id: triangle.region_id.clone(),
583            region_label: triangle.region_label.clone(),
584            region_tag: triangle.region_tag.clone(),
585            distance: hit.distance,
586            position: (ray.origin + ray.direction * hit.distance).to_array(),
587        })
588    }
589
590    fn pick_node(&self, node_index: usize, ray: &PickRay, best: &mut Option<PickHit>) {
591        let Some(node) = self.nodes.get(node_index) else {
592            return;
593        };
594        let max_distance = best
595            .as_ref()
596            .map(|hit| hit.distance)
597            .unwrap_or(f32::INFINITY);
598        let Some(bounds_distance) = ray_intersects_bounds(ray, node.bounds) else {
599            return;
600        };
601        if bounds_distance > max_distance {
602            return;
603        }
604        match node.kind {
605            PickBvhNodeKind::Leaf { start, end } => {
606                for triangle_index in start..end {
607                    let Some(triangle) = self.triangles.get(triangle_index) else {
608                        continue;
609                    };
610                    if let Some(hit_distance) =
611                        ray_intersects_triangle(ray, triangle.a, triangle.b, triangle.c)
612                    {
613                        if hit_distance > 0.0
614                            && hit_distance
615                                < best
616                                    .as_ref()
617                                    .map(|hit| hit.distance)
618                                    .unwrap_or(f32::INFINITY)
619                        {
620                            *best = Some(PickHit {
621                                triangle_index,
622                                distance: hit_distance,
623                            });
624                        }
625                    }
626                }
627            }
628            PickBvhNodeKind::Branch { left, right } => {
629                self.pick_node(left, ray, best);
630                self.pick_node(right, ray, best);
631            }
632        }
633    }
634}
635
636#[derive(Debug, Clone, Copy, PartialEq, Eq)]
637pub enum GeometrySceneCompleteness {
638    Complete,
639    Loading,
640    BoundedPreview,
641    FailedComplete,
642}
643
644#[derive(Debug, Clone)]
645pub struct GeometrySceneOverlay {
646    pub source_name: Option<String>,
647    pub status: GeometrySceneCompleteness,
648    pub quality_label: Option<String>,
649    pub format: Option<String>,
650    pub source_label: Option<String>,
651    pub allow_create_fea_study: bool,
652    pub byte_count: Option<u64>,
653    pub mesh_count: usize,
654    pub vertex_count: usize,
655    pub triangle_count: usize,
656    pub progress_percent: Option<f64>,
657    pub region_count: usize,
658    pub mapped_region_count: usize,
659    pub assembly_nodes: Vec<GeometrySceneAssemblyNode>,
660    pub regions: Vec<GeometrySceneRegionSummary>,
661    pub warnings: Vec<String>,
662}
663
664#[derive(Debug, Clone)]
665pub struct GeometrySceneAssemblyNode {
666    pub node_id: String,
667    pub label: String,
668    pub children: Vec<GeometrySceneAssemblyNode>,
669}
670
671#[derive(Debug, Clone)]
672pub struct GeometrySceneRegionSummary {
673    pub region_id: String,
674    pub label: String,
675    pub tag: Option<String>,
676    pub kind: Option<String>,
677    pub triangle_count: usize,
678}
679
680#[derive(Debug, Clone, PartialEq, Eq)]
681pub struct GeometrySceneCacheKey {
682    pub scene_id: String,
683    pub revision: u64,
684    pub chunk_count: usize,
685    pub vertex_count: usize,
686    pub index_count: usize,
687}
688
689#[derive(Debug, Clone)]
690pub struct GeometrySceneChunk {
691    pub chunk_id: String,
692    pub mesh_id: Option<String>,
693    pub label: Option<String>,
694    pub vertices: Vec<Vertex>,
695    pub indices: Option<Vec<u32>>,
696    pub render_data: RenderData,
697    pub bounds: BoundingBox,
698    pub material: Material,
699    pub regions: Vec<GeometrySceneRegion>,
700    pub owner_node_ids: Vec<String>,
701    pub visible: bool,
702}
703
704impl GeometrySceneChunk {
705    pub fn indexed_triangles(
706        chunk_id: impl Into<String>,
707        vertices: Vec<Vertex>,
708        indices: Vec<u32>,
709        material: Material,
710    ) -> Self {
711        let bounds = bounds_from_vertices(&vertices);
712        let vertex_count = vertices.len();
713        let index_count = indices.len();
714        let render_data = RenderData {
715            pipeline_type: PipelineType::Triangles,
716            vertices: vertices.clone(),
717            indices: Some(indices.clone()),
718            gpu_vertices: None,
719            bounds: Some(bounds),
720            material: material.clone(),
721            draw_calls: vec![DrawCall {
722                vertex_offset: 0,
723                vertex_count,
724                index_offset: Some(0),
725                index_count: Some(index_count),
726                instance_count: 1,
727            }],
728            image: None,
729        };
730        Self {
731            chunk_id: chunk_id.into(),
732            mesh_id: None,
733            label: None,
734            vertices,
735            indices: Some(indices),
736            render_data,
737            bounds,
738            material,
739            regions: Vec::new(),
740            owner_node_ids: Vec::new(),
741            visible: true,
742        }
743    }
744
745    pub fn from_render_data(chunk_id: impl Into<String>, render_data: RenderData) -> Self {
746        let material = render_data.material.clone();
747        let vertices = render_data.vertices.clone();
748        let indices = render_data.indices.clone();
749        let bounds = render_data
750            .bounds
751            .unwrap_or_else(|| bounds_from_vertices(&vertices));
752        Self {
753            chunk_id: chunk_id.into(),
754            mesh_id: None,
755            label: None,
756            vertices,
757            indices,
758            render_data,
759            bounds,
760            material,
761            regions: Vec::new(),
762            owner_node_ids: Vec::new(),
763            visible: true,
764        }
765    }
766
767    pub fn with_mesh_id(mut self, mesh_id: impl Into<String>) -> Self {
768        self.mesh_id = Some(mesh_id.into());
769        self
770    }
771
772    pub fn with_label(mut self, label: impl Into<String>) -> Self {
773        self.label = Some(label.into());
774        self
775    }
776
777    pub fn with_regions(mut self, regions: Vec<GeometrySceneRegion>) -> Self {
778        self.regions = regions;
779        self
780    }
781
782    pub fn with_owner_node_ids(mut self, owner_node_ids: Vec<String>) -> Self {
783        self.owner_node_ids = owner_node_ids;
784        self
785    }
786
787    pub fn triangle_count(&self) -> usize {
788        if self.render_data.pipeline_type != PipelineType::Triangles {
789            return 0;
790        }
791        self.indices
792            .as_ref()
793            .map(|indices| indices.len() / 3)
794            .unwrap_or_else(|| self.render_data.vertex_count() / 3)
795    }
796
797    pub fn render_data(&self) -> RenderData {
798        self.render_data.clone()
799    }
800
801    pub fn render_data_with_presentation(
802        &self,
803        presentation: &GeometryScenePresentation,
804    ) -> RenderData {
805        let mut render_data = self.render_data.clone();
806        if presentation.rewrites_geometry_vertices() {
807            // Presentation overlays rewrite CPU-side vertices/indices. A stale
808            // GPU vertex source would otherwise bypass the rewritten data.
809            render_data.gpu_vertices = None;
810        }
811        let is_edge_chunk = self.is_edge_chunk();
812        match presentation.display_mode {
813            GeometrySceneDisplayMode::Wireframe if !is_edge_chunk => {
814                render_data.material.alpha_mode = AlphaMode::Blend;
815                render_data.material.albedo.w = presentation
816                    .display_mode
817                    .alpha(presentation.edge_overlay_enabled);
818            }
819            GeometrySceneDisplayMode::Wireframe => {}
820            GeometrySceneDisplayMode::Edges
821                if is_edge_chunk && !presentation.edge_overlay_enabled =>
822            {
823                for vertex in &mut render_data.vertices {
824                    vertex.color[3] = 0.0;
825                }
826            }
827            GeometrySceneDisplayMode::Edges | GeometrySceneDisplayMode::Shaded => {
828                if !is_edge_chunk {
829                    let alpha = presentation
830                        .display_mode
831                        .alpha(presentation.edge_overlay_enabled)
832                        .min(render_data.material.albedo.w);
833                    render_data.material.albedo.w = alpha;
834                    render_data.material.alpha_mode = if alpha < 0.98 {
835                        AlphaMode::Blend
836                    } else {
837                        render_data.material.alpha_mode
838                    };
839                    for vertex in &mut render_data.vertices {
840                        vertex.color[3] = vertex.color[3].min(alpha);
841                    }
842                }
843            }
844        }
845
846        for highlight in &presentation.region_highlights {
847            self.apply_region_color(
848                &mut render_data,
849                &highlight.region_id,
850                highlight
851                    .color
852                    .unwrap_or_else(|| geometry_region_role_color(highlight.role.as_deref())),
853            );
854        }
855        if let Some(region_id) = presentation.hovered_region_id.as_deref() {
856            self.apply_region_color(&mut render_data, region_id, GEOMETRY_HOVER_REGION_COLOR);
857        }
858        for region_id in &presentation.selected_region_ids {
859            let colored = self.apply_region_color(
860                &mut render_data,
861                region_id,
862                GEOMETRY_SELECTED_REGION_COLOR,
863            );
864            log::info!(
865                target: "runmat_plot",
866                "geometry_scene.selection_color chunk_id={} region_id={} colored_triangles={}",
867                self.chunk_id,
868                region_id,
869                colored
870            );
871        }
872        if let Some(region_id) = presentation.selected_region_id.as_deref() {
873            let colored = self.apply_region_color(
874                &mut render_data,
875                region_id,
876                GEOMETRY_SELECTED_REGION_COLOR,
877            );
878            log::info!(
879                target: "runmat_plot",
880                "geometry_scene.selection_color chunk_id={} region_id={} colored_triangles={}",
881                self.chunk_id,
882                region_id,
883                colored
884            );
885        }
886        if let Some(section) = presentation.active_section() {
887            render_data = self.render_data_with_section(render_data, section);
888        }
889        render_data
890    }
891
892    fn render_data_with_section(
893        &self,
894        render_data: RenderData,
895        section: &GeometrySceneSection,
896    ) -> RenderData {
897        let Some(plane) = section_plane(&section.plane) else {
898            return render_data;
899        };
900        match render_data.pipeline_type {
901            PipelineType::Triangles => clip_triangle_render_data(render_data, plane),
902            PipelineType::Lines => clip_line_render_data(render_data, plane),
903            _ => render_data,
904        }
905    }
906
907    fn is_edge_chunk(&self) -> bool {
908        matches!(
909            self.render_data.pipeline_type,
910            PipelineType::Lines | PipelineType::LinesNoDepth
911        ) || self.chunk_id.contains(":edges")
912            || self
913                .label
914                .as_ref()
915                .map(|label| label.to_ascii_lowercase().contains("edge"))
916                .unwrap_or(false)
917    }
918
919    fn region_for_triangle(&self, triangle_index: u32) -> Option<&GeometrySceneRegion> {
920        self.regions.iter().find(|region| {
921            region.triangle_ranges.iter().any(|range| {
922                triangle_index >= range.start
923                    && triangle_index < range.start.saturating_add(range.count)
924            })
925        })
926    }
927
928    fn region_anchor(&self, region_id: &str) -> Option<Vec3> {
929        if self.render_data.pipeline_type != PipelineType::Triangles {
930            return None;
931        }
932        let region = self
933            .regions
934            .iter()
935            .find(|item| item.region_id == region_id)?;
936        let mut weighted_centroid = Vec3::ZERO;
937        let mut total_area = 0.0_f32;
938        let mut fallback_centroid = Vec3::ZERO;
939        let mut fallback_count = 0_usize;
940
941        for range in &region.triangle_ranges {
942            let start = range.start as usize;
943            let end = start.saturating_add(range.count as usize);
944            for triangle_index in start..end {
945                let Some((a, b, c)) = self.triangle_vertices(triangle_index) else {
946                    continue;
947                };
948                let centroid = (a + b + c) / 3.0;
949                let area = (b - a).cross(c - a).length() * 0.5;
950                if area.is_finite() && area > 1.0e-8 {
951                    weighted_centroid += centroid * area;
952                    total_area += area;
953                } else {
954                    fallback_centroid += centroid;
955                    fallback_count += 1;
956                }
957            }
958        }
959
960        if total_area > 0.0 {
961            Some(weighted_centroid / total_area)
962        } else if fallback_count > 0 {
963            Some(fallback_centroid / fallback_count as f32)
964        } else {
965            None
966        }
967    }
968
969    fn triangle_vertices(&self, triangle_index: usize) -> Option<(Vec3, Vec3, Vec3)> {
970        let vertex_at = |index: u32| {
971            self.render_data
972                .vertices
973                .get(index as usize)
974                .map(|vertex| Vec3::from_array(vertex.position))
975        };
976
977        if let Some(indices) = self.indices.as_ref() {
978            let base = triangle_index.checked_mul(3)?;
979            let triangle = indices.get(base..base + 3)?;
980            Some((
981                vertex_at(triangle[0])?,
982                vertex_at(triangle[1])?,
983                vertex_at(triangle[2])?,
984            ))
985        } else {
986            let base = triangle_index.checked_mul(3)?;
987            let a = self.render_data.vertices.get(base)?;
988            let b = self.render_data.vertices.get(base + 1)?;
989            let c = self.render_data.vertices.get(base + 2)?;
990            Some((
991                Vec3::from_array(a.position),
992                Vec3::from_array(b.position),
993                Vec3::from_array(c.position),
994            ))
995        }
996    }
997
998    fn apply_region_color(
999        &self,
1000        render_data: &mut RenderData,
1001        region_id: &str,
1002        color: [f32; 4],
1003    ) -> usize {
1004        if self.render_data.pipeline_type != PipelineType::Triangles {
1005            return 0;
1006        }
1007        let Some(region) = self.regions.iter().find(|item| item.region_id == region_id) else {
1008            log::info!(
1009                target: "runmat_plot",
1010                "geometry_scene.selection_color_missing chunk_id={} region_id={} available_regions={}",
1011                self.chunk_id,
1012                region_id,
1013                self.regions.len()
1014            );
1015            return 0;
1016        };
1017        let (Some(indices), Some(render_indices)) =
1018            (self.indices.as_ref(), render_data.indices.as_mut())
1019        else {
1020            return self.apply_direct_region_color(render_data, region, color);
1021        };
1022
1023        let mut colored = 0usize;
1024        for range in &region.triangle_ranges {
1025            let start = range.start as usize;
1026            let end = start.saturating_add(range.count as usize);
1027            for triangle_index in start..end {
1028                let base = triangle_index.saturating_mul(3);
1029                let Some(triangle) = indices.get(base..base + 3) else {
1030                    continue;
1031                };
1032                if render_indices.get(base..base + 3).is_none() {
1033                    continue;
1034                }
1035                let mut isolated_indices = [0_u32; 3];
1036                let mut isolated = true;
1037                for (slot, vertex_index) in triangle.iter().copied().enumerate() {
1038                    let Some(vertex) = render_data.vertices.get(vertex_index as usize).copied()
1039                    else {
1040                        isolated = false;
1041                        break;
1042                    };
1043                    let mut vertex = vertex;
1044                    vertex.color = color;
1045                    let next_index = render_data.vertices.len();
1046                    if next_index > u32::MAX as usize {
1047                        isolated = false;
1048                        break;
1049                    }
1050                    render_data.vertices.push(vertex);
1051                    isolated_indices[slot] = next_index as u32;
1052                }
1053                if isolated {
1054                    render_indices[base..base + 3].copy_from_slice(&isolated_indices);
1055                    colored += 1;
1056                }
1057            }
1058        }
1059        colored
1060    }
1061
1062    fn apply_direct_region_color(
1063        &self,
1064        render_data: &mut RenderData,
1065        region: &GeometrySceneRegion,
1066        color: [f32; 4],
1067    ) -> usize {
1068        if render_data.indices.is_some() {
1069            return 0;
1070        }
1071        let mut colored = 0usize;
1072        for range in &region.triangle_ranges {
1073            let start = range.start as usize;
1074            let end = start.saturating_add(range.count as usize);
1075            for triangle_index in start..end {
1076                let base = triangle_index.saturating_mul(3);
1077                let Some(triangle) = render_data.vertices.get_mut(base..base + 3) else {
1078                    continue;
1079                };
1080                for vertex in triangle {
1081                    vertex.color = color;
1082                }
1083                colored += 1;
1084            }
1085        }
1086        colored
1087    }
1088}
1089
1090fn section_plane(plane: &GeometrySceneSectionPlane) -> Option<(Vec3, Vec3)> {
1091    let normal = Vec3::from_array(plane.normal).normalize_or_zero();
1092    if normal.length_squared() <= f32::EPSILON {
1093        return None;
1094    }
1095    let origin = plane
1096        .origin
1097        .map(Vec3::from_array)
1098        .unwrap_or_else(|| normal * plane.offset.unwrap_or(0.0));
1099    if !origin.is_finite() {
1100        return None;
1101    }
1102    Some((normal, origin))
1103}
1104
1105fn geometry_region_role_color(role: Option<&str>) -> [f32; 4] {
1106    match role.unwrap_or_default() {
1107        "material" => GEOMETRY_MATERIAL_REGION_COLOR,
1108        "boundary" => GEOMETRY_BOUNDARY_REGION_COLOR,
1109        "driving" => GEOMETRY_DRIVING_REGION_COLOR,
1110        "selection" => GEOMETRY_SELECTED_REGION_COLOR,
1111        _ => GEOMETRY_HOVER_REGION_COLOR,
1112    }
1113}
1114
1115fn clip_triangle_render_data(mut render_data: RenderData, plane: (Vec3, Vec3)) -> RenderData {
1116    let mut clipped_vertices = Vec::new();
1117    if let Some(indices) = render_data.indices.as_ref() {
1118        for triangle in indices.chunks_exact(3) {
1119            let Some(vertices) = triangle_vertices_from_indices(&render_data.vertices, triangle)
1120            else {
1121                continue;
1122            };
1123            push_clipped_triangle(vertices, plane, &mut clipped_vertices);
1124        }
1125    } else {
1126        for triangle in render_data.vertices.chunks_exact(3) {
1127            push_clipped_triangle(
1128                [triangle[0], triangle[1], triangle[2]],
1129                plane,
1130                &mut clipped_vertices,
1131            );
1132        }
1133    }
1134    let bounds = bounds_from_vertices(&clipped_vertices);
1135    let vertex_count = clipped_vertices.len();
1136    render_data.vertices = clipped_vertices;
1137    render_data.indices = None;
1138    render_data.bounds = Some(bounds);
1139    render_data.draw_calls = vec![DrawCall {
1140        vertex_offset: 0,
1141        vertex_count,
1142        index_offset: None,
1143        index_count: None,
1144        instance_count: 1,
1145    }];
1146    render_data
1147}
1148
1149fn clip_line_render_data(mut render_data: RenderData, plane: (Vec3, Vec3)) -> RenderData {
1150    let mut clipped_vertices = Vec::new();
1151    if let Some(indices) = render_data.indices.as_ref() {
1152        for line in indices.chunks_exact(2) {
1153            let Some(a) = render_data.vertices.get(line[0] as usize).copied() else {
1154                continue;
1155            };
1156            let Some(b) = render_data.vertices.get(line[1] as usize).copied() else {
1157                continue;
1158            };
1159            push_clipped_line(a, b, plane, &mut clipped_vertices);
1160        }
1161    } else {
1162        for line in render_data.vertices.chunks_exact(2) {
1163            push_clipped_line(line[0], line[1], plane, &mut clipped_vertices);
1164        }
1165    }
1166    let bounds = bounds_from_vertices(&clipped_vertices);
1167    let vertex_count = clipped_vertices.len();
1168    render_data.vertices = clipped_vertices;
1169    render_data.indices = None;
1170    render_data.bounds = Some(bounds);
1171    render_data.draw_calls = vec![DrawCall {
1172        vertex_offset: 0,
1173        vertex_count,
1174        index_offset: None,
1175        index_count: None,
1176        instance_count: 1,
1177    }];
1178    render_data
1179}
1180
1181fn triangle_vertices_from_indices(vertices: &[Vertex], triangle: &[u32]) -> Option<[Vertex; 3]> {
1182    Some([
1183        *vertices.get(*triangle.first()? as usize)?,
1184        *vertices.get(*triangle.get(1)? as usize)?,
1185        *vertices.get(*triangle.get(2)? as usize)?,
1186    ])
1187}
1188
1189fn push_clipped_triangle(vertices: [Vertex; 3], plane: (Vec3, Vec3), output: &mut Vec<Vertex>) {
1190    let clipped = clip_polygon_to_plane(&vertices, plane);
1191    if clipped.len() < 3 {
1192        return;
1193    }
1194    for index in 1..clipped.len().saturating_sub(1) {
1195        output.push(clipped[0]);
1196        output.push(clipped[index]);
1197        output.push(clipped[index + 1]);
1198    }
1199}
1200
1201fn push_clipped_line(a: Vertex, b: Vertex, plane: (Vec3, Vec3), output: &mut Vec<Vertex>) {
1202    let distance_a = signed_distance_to_plane(a, plane);
1203    let distance_b = signed_distance_to_plane(b, plane);
1204    let inside_a = distance_a >= -SECTION_DISTANCE_EPSILON;
1205    let inside_b = distance_b >= -SECTION_DISTANCE_EPSILON;
1206    match (inside_a, inside_b) {
1207        (true, true) => {
1208            output.push(a);
1209            output.push(b);
1210        }
1211        (false, false) => {}
1212        (true, false) => {
1213            output.push(a);
1214            output.push(interpolate_vertex(
1215                a,
1216                b,
1217                section_intersection_t(distance_a, distance_b),
1218            ));
1219        }
1220        (false, true) => {
1221            output.push(interpolate_vertex(
1222                a,
1223                b,
1224                section_intersection_t(distance_a, distance_b),
1225            ));
1226            output.push(b);
1227        }
1228    }
1229}
1230
1231fn clip_polygon_to_plane(vertices: &[Vertex], plane: (Vec3, Vec3)) -> Vec<Vertex> {
1232    let Some(mut previous) = vertices.last().copied() else {
1233        return Vec::new();
1234    };
1235    let mut previous_distance = signed_distance_to_plane(previous, plane);
1236    let mut previous_inside = previous_distance >= -SECTION_DISTANCE_EPSILON;
1237    let mut output = Vec::with_capacity(vertices.len() + 1);
1238    for current in vertices.iter().copied() {
1239        let current_distance = signed_distance_to_plane(current, plane);
1240        let current_inside = current_distance >= -SECTION_DISTANCE_EPSILON;
1241        if current_inside != previous_inside {
1242            output.push(interpolate_vertex(
1243                previous,
1244                current,
1245                section_intersection_t(previous_distance, current_distance),
1246            ));
1247        }
1248        if current_inside {
1249            output.push(current);
1250        }
1251        previous = current;
1252        previous_distance = current_distance;
1253        previous_inside = current_inside;
1254    }
1255    output
1256}
1257
1258fn signed_distance_to_plane(vertex: Vertex, (normal, origin): (Vec3, Vec3)) -> f32 {
1259    (Vec3::from_array(vertex.position) - origin).dot(normal)
1260}
1261
1262fn section_intersection_t(distance_a: f32, distance_b: f32) -> f32 {
1263    let denominator = distance_a - distance_b;
1264    if denominator.abs() <= f32::EPSILON {
1265        0.0
1266    } else {
1267        (distance_a / denominator).clamp(0.0, 1.0)
1268    }
1269}
1270
1271fn interpolate_vertex(a: Vertex, b: Vertex, t: f32) -> Vertex {
1272    let position = Vec3::from_array(a.position).lerp(Vec3::from_array(b.position), t);
1273    let color = Vec4::from_array(a.color).lerp(Vec4::from_array(b.color), t);
1274    let normal = Vec3::from_array(a.normal)
1275        .lerp(Vec3::from_array(b.normal), t)
1276        .normalize_or_zero();
1277    let tex_coords_a = Vec2::from_array(a.tex_coords);
1278    let tex_coords_b = Vec2::from_array(b.tex_coords);
1279    Vertex {
1280        position: position.to_array(),
1281        color: color.to_array(),
1282        normal: if normal.length_squared() > f32::EPSILON {
1283            normal.to_array()
1284        } else {
1285            a.normal
1286        },
1287        tex_coords: tex_coords_a.lerp(tex_coords_b, t).to_array(),
1288    }
1289}
1290
1291#[derive(Debug, Clone, PartialEq, Eq)]
1292pub struct GeometrySceneRegion {
1293    pub region_id: String,
1294    pub label: Option<String>,
1295    pub tag: Option<String>,
1296    pub triangle_ranges: Vec<GeometrySceneTriangleRange>,
1297}
1298
1299impl GeometrySceneRegion {
1300    pub fn new(
1301        region_id: impl Into<String>,
1302        label: Option<String>,
1303        tag: Option<String>,
1304        triangle_ranges: Vec<GeometrySceneTriangleRange>,
1305    ) -> Self {
1306        Self {
1307            region_id: region_id.into(),
1308            label,
1309            tag,
1310            triangle_ranges,
1311        }
1312    }
1313}
1314
1315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1316pub struct GeometrySceneTriangleRange {
1317    pub start: u32,
1318    pub count: u32,
1319}
1320
1321impl GeometrySceneTriangleRange {
1322    pub fn new(start: u32, count: u32) -> Self {
1323        Self { start, count }
1324    }
1325}
1326
1327pub fn cad_default_material() -> Material {
1328    Material {
1329        albedo: Vec4::new(0.46, 0.49, 0.48, 1.0),
1330        roughness: 0.72,
1331        metallic: 0.0,
1332        emissive: Vec4::ZERO,
1333        alpha_mode: AlphaMode::Opaque,
1334        double_sided: true,
1335    }
1336}
1337
1338pub fn vertex(position: [f32; 3], color: [f32; 4], normal: [f32; 3]) -> Vertex {
1339    Vertex {
1340        position,
1341        color,
1342        normal,
1343        tex_coords: [0.0, 0.0],
1344    }
1345}
1346
1347fn bounds_from_vertices(vertices: &[Vertex]) -> BoundingBox {
1348    if vertices.is_empty() {
1349        return BoundingBox::default();
1350    }
1351    let mut bounds = BoundingBox::new(
1352        Vec3::from_array(vertices[0].position),
1353        Vec3::from_array(vertices[0].position),
1354    );
1355    for item in vertices.iter().skip(1) {
1356        bounds.expand(Vec3::from_array(item.position));
1357    }
1358    bounds
1359}
1360
1361fn combined_chunk_bounds(chunks: &[GeometrySceneChunk]) -> BoundingBox {
1362    let mut bounds = BoundingBox::default();
1363    for chunk in chunks {
1364        bounds.expand_by_box(&chunk.bounds);
1365    }
1366    bounds
1367}
1368
1369fn annotation_arrow_length(bounds: BoundingBox) -> f32 {
1370    let size = bounds.size();
1371    let diagonal = size.length();
1372    if diagonal.is_finite() && diagonal > 1.0e-6 {
1373        diagonal * 0.075
1374    } else {
1375        1.0
1376    }
1377}
1378
1379fn normalized_annotation_direction(direction: [f32; 3]) -> Option<Vec3> {
1380    let direction = Vec3::from_array(direction);
1381    let length = direction.length();
1382    (length.is_finite() && length > 1.0e-8).then_some(direction / length)
1383}
1384
1385fn append_annotation_arrow(
1386    vertices: &mut Vec<Vertex>,
1387    anchor: Vec3,
1388    direction: Vec3,
1389    length: f32,
1390    color: [f32; 4],
1391) {
1392    let start = anchor;
1393    let end = anchor + direction * length;
1394    append_annotation_line(vertices, start, end, color);
1395
1396    let side = perpendicular_unit(direction);
1397    let wing_base = end - direction * (length * 0.28);
1398    let wing_size = length * 0.12;
1399    append_annotation_line(vertices, end, wing_base + side * wing_size, color);
1400    append_annotation_line(vertices, end, wing_base - side * wing_size, color);
1401}
1402
1403fn append_annotation_line(vertices: &mut Vec<Vertex>, start: Vec3, end: Vec3, color: [f32; 4]) {
1404    vertices.push(vertex(start.to_array(), color, [0.0, 0.0, 1.0]));
1405    vertices.push(vertex(end.to_array(), color, [0.0, 0.0, 1.0]));
1406}
1407
1408fn perpendicular_unit(direction: Vec3) -> Vec3 {
1409    let reference = if direction.z.abs() < 0.9 {
1410        Vec3::Z
1411    } else {
1412        Vec3::Y
1413    };
1414    let side = direction.cross(reference);
1415    let length = side.length();
1416    if length > 1.0e-8 {
1417        side / length
1418    } else {
1419        Vec3::X
1420    }
1421}
1422
1423fn stable_node_id(scene_id: &str, revision: u64, index: usize, chunk_id: &str) -> u64 {
1424    const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
1425    const FNV_PRIME: u64 = 0x100000001b3;
1426    let mut hash = FNV_OFFSET_BASIS;
1427    for byte in scene_id
1428        .as_bytes()
1429        .iter()
1430        .chain(chunk_id.as_bytes())
1431        .copied()
1432    {
1433        hash ^= u64::from(byte);
1434        hash = hash.wrapping_mul(FNV_PRIME);
1435    }
1436    hash ^= revision;
1437    hash = hash.wrapping_mul(FNV_PRIME);
1438    hash ^ index as u64
1439}
1440
1441#[derive(Debug, Clone)]
1442struct IndexedTriangle {
1443    a: Vec3,
1444    b: Vec3,
1445    c: Vec3,
1446    bounds: BoundingBox,
1447    centroid: Vec3,
1448    mesh_id: Option<String>,
1449    chunk_id: String,
1450    triangle_index: usize,
1451    region_id: Option<String>,
1452    region_label: Option<String>,
1453    region_tag: Option<String>,
1454}
1455
1456#[derive(Debug, Clone)]
1457struct PickBvhNode {
1458    bounds: BoundingBox,
1459    kind: PickBvhNodeKind,
1460}
1461
1462#[derive(Debug, Clone, Copy)]
1463enum PickBvhNodeKind {
1464    Leaf { start: usize, end: usize },
1465    Branch { left: usize, right: usize },
1466}
1467
1468#[derive(Debug, Clone, Copy)]
1469struct PickRay {
1470    origin: Vec3,
1471    direction: Vec3,
1472}
1473
1474#[derive(Debug, Clone, Copy)]
1475struct PickHit {
1476    triangle_index: usize,
1477    distance: f32,
1478}
1479
1480fn build_pick_bvh_node(
1481    nodes: &mut Vec<PickBvhNode>,
1482    triangles: &mut [IndexedTriangle],
1483    start: usize,
1484    end: usize,
1485) -> Option<usize> {
1486    if start >= end {
1487        return None;
1488    }
1489    let bounds = combined_triangle_bounds(&triangles[start..end]);
1490    let node_index = nodes.len();
1491    nodes.push(PickBvhNode {
1492        bounds,
1493        kind: PickBvhNodeKind::Leaf { start, end },
1494    });
1495    const LEAF_TRIANGLES: usize = 32;
1496    if end - start <= LEAF_TRIANGLES {
1497        return Some(node_index);
1498    }
1499    let centroid_bounds = combined_centroid_bounds(&triangles[start..end]);
1500    let extent = centroid_bounds.max - centroid_bounds.min;
1501    let axis = if extent.x >= extent.y && extent.x >= extent.z {
1502        0
1503    } else if extent.y >= extent.z {
1504        1
1505    } else {
1506        2
1507    };
1508    triangles[start..end].sort_by(|a, b| {
1509        a.centroid[axis]
1510            .partial_cmp(&b.centroid[axis])
1511            .unwrap_or(std::cmp::Ordering::Equal)
1512    });
1513    let mid = start + (end - start) / 2;
1514    let left = build_pick_bvh_node(nodes, triangles, start, mid);
1515    let right = build_pick_bvh_node(nodes, triangles, mid, end);
1516    if let (Some(left), Some(right)) = (left, right) {
1517        nodes[node_index].kind = PickBvhNodeKind::Branch { left, right };
1518    }
1519    Some(node_index)
1520}
1521
1522fn triangle_bounds(a: Vec3, b: Vec3, c: Vec3) -> BoundingBox {
1523    let mut bounds = BoundingBox::new(a, a);
1524    bounds.expand(b);
1525    bounds.expand(c);
1526    bounds
1527}
1528
1529fn combined_triangle_bounds(triangles: &[IndexedTriangle]) -> BoundingBox {
1530    let mut bounds = BoundingBox::default();
1531    for triangle in triangles {
1532        bounds.expand_by_box(&triangle.bounds);
1533    }
1534    bounds
1535}
1536
1537fn combined_centroid_bounds(triangles: &[IndexedTriangle]) -> BoundingBox {
1538    let Some(first) = triangles.first() else {
1539        return BoundingBox::default();
1540    };
1541    let mut bounds = BoundingBox::new(first.centroid, first.centroid);
1542    for triangle in triangles.iter().skip(1) {
1543        bounds.expand(triangle.centroid);
1544    }
1545    bounds
1546}
1547
1548fn ray_intersects_bounds(ray: &PickRay, bounds: BoundingBox) -> Option<f32> {
1549    let mut t_min: f32 = 0.0;
1550    let mut t_max = f32::INFINITY;
1551    for axis in 0..3 {
1552        let origin = ray.origin[axis];
1553        let direction = ray.direction[axis];
1554        let min = bounds.min[axis];
1555        let max = bounds.max[axis];
1556        if direction.abs() < 1e-8 {
1557            if origin < min || origin > max {
1558                return None;
1559            }
1560            continue;
1561        }
1562        let inv_direction = 1.0 / direction;
1563        let mut t0 = (min - origin) * inv_direction;
1564        let mut t1 = (max - origin) * inv_direction;
1565        if t0 > t1 {
1566            std::mem::swap(&mut t0, &mut t1);
1567        }
1568        t_min = t_min.max(t0);
1569        t_max = t_max.min(t1);
1570        if t_max < t_min {
1571            return None;
1572        }
1573    }
1574    Some(t_min.max(0.0))
1575}
1576
1577fn ray_intersects_triangle(ray: &PickRay, a: Vec3, b: Vec3, c: Vec3) -> Option<f32> {
1578    let edge1 = b - a;
1579    let edge2 = c - a;
1580    let pvec = ray.direction.cross(edge2);
1581    let det = edge1.dot(pvec);
1582    if det.abs() < 1e-7 {
1583        return None;
1584    }
1585    let inv_det = 1.0 / det;
1586    let tvec = ray.origin - a;
1587    let u = tvec.dot(pvec) * inv_det;
1588    if !(0.0..=1.0).contains(&u) {
1589        return None;
1590    }
1591    let qvec = tvec.cross(edge1);
1592    let v = ray.direction.dot(qvec) * inv_det;
1593    if v < 0.0 || u + v > 1.0 {
1594        return None;
1595    }
1596    let t = edge2.dot(qvec) * inv_det;
1597    (t > 1e-6).then_some(t)
1598}
1599
1600#[cfg(test)]
1601mod tests {
1602    use super::*;
1603    use crate::core::ProjectionType;
1604
1605    #[test]
1606    fn pick_index_returns_region_for_triangle() {
1607        let material = cad_default_material();
1608        let chunk = GeometrySceneChunk::indexed_triangles(
1609            "face_chunk",
1610            vec![
1611                vertex([-1.0, -1.0, 0.0], [0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 1.0]),
1612                vertex([1.0, -1.0, 0.0], [0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 1.0]),
1613                vertex([0.0, 1.0, 0.0], [0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 1.0]),
1614            ],
1615            vec![0, 1, 2],
1616            material,
1617        )
1618        .with_regions(vec![GeometrySceneRegion::new(
1619            "face_a",
1620            Some("Face A".to_string()),
1621            Some("cad-face".to_string()),
1622            vec![GeometrySceneTriangleRange::new(0, 1)],
1623        )]);
1624        let scene = GeometryScene::new("scene", 1, vec![chunk]);
1625        let index = GeometryScenePickIndex::build(&scene);
1626        let mut camera = Camera::new();
1627        camera.position = Vec3::new(0.0, 0.0, 5.0);
1628        camera.target = Vec3::ZERO;
1629        camera.up = Vec3::Y;
1630        camera.aspect_ratio = 1.0;
1631        camera.projection = ProjectionType::Perspective {
1632            fov: 45.0_f32.to_radians(),
1633            near: 0.1,
1634            far: 100.0,
1635        };
1636        camera.mark_dirty();
1637
1638        let hit = index.pick(GeometryScenePickRequest {
1639            camera,
1640            surface_size: [800.0, 800.0],
1641            position: [400.0, 400.0],
1642        });
1643        assert_eq!(
1644            hit.and_then(|hit| hit.region_id),
1645            Some("face_a".to_string())
1646        );
1647    }
1648
1649    #[test]
1650    fn presentation_selection_colors_indexed_triangle_region() {
1651        let material = cad_default_material();
1652        let chunk = GeometrySceneChunk::indexed_triangles(
1653            "face_chunk",
1654            vec![
1655                vertex([-1.0, -1.0, 0.0], [0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 1.0]),
1656                vertex([1.0, -1.0, 0.0], [0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 1.0]),
1657                vertex([0.0, 1.0, 0.0], [0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 1.0]),
1658                vertex([2.0, -1.0, 0.0], [0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 1.0]),
1659            ],
1660            vec![0, 1, 2, 1, 3, 2],
1661            material,
1662        )
1663        .with_regions(vec![GeometrySceneRegion::new(
1664            "face_b",
1665            Some("Face B".to_string()),
1666            Some("cad-face".to_string()),
1667            vec![GeometrySceneTriangleRange::new(1, 1)],
1668        )]);
1669
1670        let render_data = chunk.render_data_with_presentation(&GeometryScenePresentation {
1671            selected_region_ids: vec!["face_b".to_string()],
1672            ..Default::default()
1673        });
1674
1675        assert_eq!(render_data.vertices.len(), 7);
1676        assert_eq!(
1677            render_data.indices.as_deref(),
1678            Some(&[0, 1, 2, 4, 5, 6][..])
1679        );
1680        assert_eq!(
1681            render_data.vertices[4].color,
1682            GEOMETRY_SELECTED_REGION_COLOR
1683        );
1684        assert_eq!(
1685            render_data.vertices[5].color,
1686            GEOMETRY_SELECTED_REGION_COLOR
1687        );
1688        assert_eq!(
1689            render_data.vertices[6].color,
1690            GEOMETRY_SELECTED_REGION_COLOR
1691        );
1692    }
1693
1694    #[test]
1695    fn presentation_selection_colors_direct_triangle_region() {
1696        let material = cad_default_material();
1697        let base_color = [0.5, 0.5, 0.5, 1.0];
1698        let vertices = vec![
1699            vertex([-1.0, -1.0, 0.0], base_color, [0.0, 0.0, 1.0]),
1700            vertex([1.0, -1.0, 0.0], base_color, [0.0, 0.0, 1.0]),
1701            vertex([0.0, 1.0, 0.0], base_color, [0.0, 0.0, 1.0]),
1702            vertex([1.0, -1.0, 0.0], base_color, [0.0, 0.0, 1.0]),
1703            vertex([2.0, -1.0, 0.0], base_color, [0.0, 0.0, 1.0]),
1704            vertex([0.0, 1.0, 0.0], base_color, [0.0, 0.0, 1.0]),
1705        ];
1706        let chunk = GeometrySceneChunk::from_render_data(
1707            "face_chunk",
1708            RenderData {
1709                pipeline_type: PipelineType::Triangles,
1710                vertices,
1711                indices: None,
1712                gpu_vertices: None,
1713                bounds: None,
1714                material,
1715                draw_calls: vec![DrawCall {
1716                    vertex_offset: 0,
1717                    vertex_count: 6,
1718                    index_offset: None,
1719                    index_count: None,
1720                    instance_count: 1,
1721                }],
1722                image: None,
1723            },
1724        )
1725        .with_regions(vec![GeometrySceneRegion::new(
1726            "face_b",
1727            Some("Face B".to_string()),
1728            Some("cad-face".to_string()),
1729            vec![GeometrySceneTriangleRange::new(1, 1)],
1730        )]);
1731
1732        let render_data = chunk.render_data_with_presentation(&GeometryScenePresentation {
1733            selected_region_id: Some("face_b".to_string()),
1734            ..Default::default()
1735        });
1736
1737        assert!(render_data.indices.is_none());
1738        assert_eq!(render_data.vertices.len(), 6);
1739        assert_eq!(render_data.vertices[0].color, base_color);
1740        assert_eq!(render_data.vertices[1].color, base_color);
1741        assert_eq!(render_data.vertices[2].color, base_color);
1742        assert_eq!(
1743            render_data.vertices[3].color,
1744            GEOMETRY_SELECTED_REGION_COLOR
1745        );
1746        assert_eq!(
1747            render_data.vertices[4].color,
1748            GEOMETRY_SELECTED_REGION_COLOR
1749        );
1750        assert_eq!(
1751            render_data.vertices[5].color,
1752            GEOMETRY_SELECTED_REGION_COLOR
1753        );
1754    }
1755
1756    #[test]
1757    fn presentation_resolves_hidden_and_isolated_owner_visibility() {
1758        let current_hidden = BTreeSet::from(["panel_b".to_string()]);
1759        let all_owner_ids = ["panel_a", "panel_b", "panel_c"];
1760
1761        assert_eq!(
1762            GeometryScenePresentation::default()
1763                .resolved_hidden_owner_node_ids(all_owner_ids, &current_hidden),
1764            current_hidden
1765        );
1766
1767        assert_eq!(
1768            GeometryScenePresentation {
1769                hidden_owner_node_ids: Some(vec![
1770                    " panel_a ".to_string(),
1771                    String::new(),
1772                    "panel_a".to_string(),
1773                ]),
1774                ..Default::default()
1775            }
1776            .resolved_hidden_owner_node_ids(all_owner_ids, &BTreeSet::new()),
1777            BTreeSet::from(["panel_a".to_string()])
1778        );
1779
1780        assert_eq!(
1781            GeometryScenePresentation {
1782                hidden_owner_node_ids: Some(vec!["panel_c".to_string()]),
1783                isolated_owner_node_ids: Some(vec!["panel_a".to_string()]),
1784                ..Default::default()
1785            }
1786            .resolved_hidden_owner_node_ids(all_owner_ids, &BTreeSet::new()),
1787            BTreeSet::from(["panel_b".to_string(), "panel_c".to_string()])
1788        );
1789
1790        assert_eq!(
1791            GeometryScenePresentation {
1792                hidden_owner_node_ids: Some(Vec::new()),
1793                isolated_owner_node_ids: None,
1794                ..Default::default()
1795            }
1796            .resolved_hidden_owner_node_ids(all_owner_ids, &current_hidden),
1797            BTreeSet::new()
1798        );
1799    }
1800
1801    #[test]
1802    fn presentation_section_distinguishes_preserve_clear_and_plane() {
1803        let preserve: GeometryScenePresentation =
1804            serde_json::from_value(serde_json::json!({})).expect("preserve presentation");
1805        assert_eq!(preserve.section, None);
1806
1807        let clear: GeometryScenePresentation =
1808            serde_json::from_value(serde_json::json!({ "section": null }))
1809                .expect("clear presentation");
1810        assert_eq!(clear.section, Some(None));
1811
1812        let sectioned: GeometryScenePresentation = serde_json::from_value(serde_json::json!({
1813            "section": {
1814                "plane": {
1815                    "normal": [1.0, 0.0, 0.0],
1816                    "origin": [0.0, 0.0, 0.0],
1817                    "label": "midplane"
1818                }
1819            }
1820        }))
1821        .expect("section presentation");
1822        let section = sectioned
1823            .section
1824            .as_ref()
1825            .and_then(Option::as_ref)
1826            .expect("section");
1827        assert_eq!(section.plane.normal, [1.0, 0.0, 0.0]);
1828        assert_eq!(section.plane.label.as_deref(), Some("midplane"));
1829    }
1830
1831    #[test]
1832    fn presentation_accepts_bounded_view_presets() {
1833        let front: GeometryScenePresentation =
1834            serde_json::from_value(serde_json::json!({ "viewPreset": "front" }))
1835                .expect("front view presentation");
1836        assert_eq!(front.view_preset, Some(GeometrySceneViewPreset::Front));
1837
1838        let isometric: GeometryScenePresentation =
1839            serde_json::from_value(serde_json::json!({ "viewPreset": "isometric" }))
1840                .expect("isometric view presentation");
1841        assert_eq!(
1842            isometric.view_preset,
1843            Some(GeometrySceneViewPreset::Isometric)
1844        );
1845    }
1846
1847    #[test]
1848    fn presentation_section_clips_triangle_render_data() {
1849        let material = cad_default_material();
1850        let chunk = GeometrySceneChunk::indexed_triangles(
1851            "face_chunk",
1852            vec![
1853                vertex([-1.0, -1.0, 0.0], [0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 1.0]),
1854                vertex([1.0, -1.0, 0.0], [0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 1.0]),
1855                vertex([0.0, 1.0, 0.0], [0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 1.0]),
1856            ],
1857            vec![0, 1, 2],
1858            material,
1859        );
1860        let render_data = chunk.render_data_with_presentation(&GeometryScenePresentation {
1861            section: Some(Some(GeometrySceneSection {
1862                plane: GeometrySceneSectionPlane {
1863                    normal: [1.0, 0.0, 0.0],
1864                    origin: Some([0.0, 0.0, 0.0]),
1865                    offset: None,
1866                    label: Some("midplane".to_string()),
1867                },
1868            })),
1869            ..Default::default()
1870        });
1871
1872        assert_eq!(render_data.pipeline_type, PipelineType::Triangles);
1873        assert!(render_data.indices.is_none());
1874        assert_eq!(render_data.vertices.len(), 6);
1875        assert_eq!(
1876            render_data.draw_calls[0].vertex_count,
1877            render_data.vertices.len()
1878        );
1879        assert!(render_data
1880            .vertices
1881            .iter()
1882            .all(|vertex| vertex.position[0] >= -SECTION_DISTANCE_EPSILON));
1883    }
1884
1885    #[test]
1886    fn presentation_region_annotations_emit_marker_and_vector_nodes() {
1887        let material = cad_default_material();
1888        let chunk = GeometrySceneChunk::indexed_triangles(
1889            "face_chunk",
1890            vec![
1891                vertex([-1.0, -1.0, 0.0], [0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 1.0]),
1892                vertex([1.0, -1.0, 0.0], [0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 1.0]),
1893                vertex([0.0, 1.0, 0.0], [0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 1.0]),
1894            ],
1895            vec![0, 1, 2],
1896            material,
1897        )
1898        .with_regions(vec![GeometrySceneRegion::new(
1899            "loaded_face",
1900            Some("Loaded face".to_string()),
1901            Some("cad-face".to_string()),
1902            vec![GeometrySceneTriangleRange::new(0, 1)],
1903        )]);
1904        let scene = GeometryScene::new("scene", 1, vec![chunk]);
1905        let nodes = scene.nodes_with_presentation(&GeometryScenePresentation {
1906            region_annotations: vec![GeometrySceneRegionAnnotation {
1907                region_id: "loaded_face".to_string(),
1908                color: Some([0.9, 0.1, 0.1, 1.0]),
1909                role: Some("load".to_string()),
1910                label: Some("load".to_string()),
1911                direction: Some([0.0, 0.0, 1.0]),
1912                size: Some(18.0),
1913            }],
1914            ..Default::default()
1915        });
1916
1917        let marker = nodes
1918            .iter()
1919            .find(|node| node.name == "FEA region markers")
1920            .and_then(|node| node.render_data.as_ref())
1921            .expect("marker annotation node");
1922        assert_eq!(marker.pipeline_type, PipelineType::Points);
1923        assert_eq!(marker.vertices.len(), 1);
1924        assert!((marker.vertices[0].position[1] - (-1.0 / 3.0)).abs() < 1.0e-6);
1925        assert_eq!(marker.vertices[0].normal[2], 18.0);
1926
1927        let vector = nodes
1928            .iter()
1929            .find(|node| node.name == "FEA load vectors")
1930            .and_then(|node| node.render_data.as_ref())
1931            .expect("vector annotation node");
1932        assert_eq!(vector.pipeline_type, PipelineType::Lines);
1933        assert_eq!(vector.vertices.len(), 6);
1934        assert!(vector.vertices[1].position[2] > vector.vertices[0].position[2]);
1935    }
1936}