Skip to main content

viewport_lib/renderer/picking/
point.rs

1//! CPU ray-cast pick: find the nearest item or sub-element under the cursor.
2
3use super::*;
4
5impl ViewportRenderer {
6    // -----------------------------------------------------------------------
7    // Unified CPU pick : renderer.pick()
8    // -----------------------------------------------------------------------
9
10    /// Pick the nearest item or sub-element under `click_pos`.
11    ///
12    /// Dispatches across all item types retained from the last `prepare()` call.
13    /// The `mask` controls which item types and sub-element levels participate.
14    ///
15    /// Returns `None` if nothing matching the mask is under the cursor.
16    ///
17    /// # Arguments
18    /// * `click_pos`     - cursor position in viewport pixels (top-left origin)
19    /// * `viewport_size` - viewport width x height in pixels
20    /// * `view_proj`     - combined view x projection matrix from the last frame
21    /// * `mask`          - which item types and sub-element levels to include
22    ///
23    /// # Example
24    /// ```rust,ignore
25    /// if let Some(hit) = renderer.pick(cursor, vp_size, view_proj, PickMask::FACE) {
26    ///     println!("hit face {:?} on object {}", hit.sub_object, hit.id);
27    /// }
28    /// ```
29    pub fn pick(
30        &self,
31        click_pos: glam::Vec2,
32        viewport_size: glam::Vec2,
33        view_proj: glam::Mat4,
34        mask: crate::interaction::pick_mask::PickMask,
35    ) -> Option<crate::interaction::picking::PickHit> {
36        use crate::interaction::pick_mask::PickMask;
37        use crate::interaction::picking::{
38            PickHit, pick_gaussian_splat_cpu, pick_point_cloud_cpu,
39            pick_transparent_volume_mesh_cpu, pick_volume_cpu, screen_to_ray,
40        };
41        use crate::interaction::sub_object::SubObjectRef;
42        use parry3d::math::{Pose, Vector};
43        use parry3d::query::{Ray, RayCast};
44
45        if !self.cpu_pick_cache_enabled {
46            warn_pick_cache_disabled();
47            return None;
48        }
49
50        if viewport_size.x <= 0.0 || viewport_size.y <= 0.0 {
51            return None;
52        }
53
54        let view_proj_inv = view_proj.inverse();
55        let (ray_origin, ray_dir) = screen_to_ray(click_pos, viewport_size, view_proj_inv);
56
57        let wants_face = mask.intersects(PickMask::FACE);
58        let wants_vertex = mask.intersects(PickMask::VERTEX);
59        let wants_cell = mask.intersects(PickMask::CELL);
60        let wants_cloud = mask.intersects(PickMask::CLOUD_POINT);
61        let wants_splat = mask.intersects(PickMask::SPLAT);
62        let wants_object = mask.intersects(PickMask::OBJECT);
63        let wants_mesh_sub = wants_face || wants_vertex || mask.intersects(PickMask::EDGE);
64
65        // (toi, hit) -- nearest hit so far across all types.
66        let mut best: Option<(f32, PickHit)> = None;
67
68        let mut consider = |toi: f32, hit: PickHit| {
69            if best.as_ref().map_or(true, |(bt, _)| toi < *bt) {
70                best = Some((toi, hit));
71            }
72        };
73
74        // Build lookup for opaque volume mesh face_to_cell maps (used in section 1
75        // to convert surface Face hits to Cell hits).
76        let vm_cell_map: std::collections::HashMap<u64, &[u32]> = self
77            .pick_volume_mesh_items
78            .iter()
79            .filter(|item| item.settings.pick_id != PickId::NONE && !item.face_to_cell.is_empty())
80            .map(|item| (item.settings.pick_id.0, item.face_to_cell.as_slice()))
81            .collect();
82
83        // 1. Surface mesh picks (FACE, VERTEX, EDGE, CELL, or OBJECT fallback).
84        if wants_mesh_sub || wants_cell || wants_object {
85            let ray = Ray::new(
86                Vector::new(ray_origin.x, ray_origin.y, ray_origin.z),
87                Vector::new(ray_dir.x, ray_dir.y, ray_dir.z),
88            );
89            for item in &self.pick_scene_items {
90                if item.settings.hidden || item.settings.pick_id == PickId::NONE {
91                    continue;
92                }
93                let Some(mesh) = self.resources.mesh_store.get(item.mesh_id) else {
94                    continue;
95                };
96                let (Some(positions), Some(indices)) = (&mesh.cpu_positions, &mesh.cpu_indices)
97                else {
98                    continue;
99                };
100
101                let model = glam::Mat4::from_cols_array_2d(&item.model);
102
103                // Bake the full model matrix into vertex positions so that
104                // non-uniform scale is handled correctly.
105                let verts: Vec<Vector> = positions
106                    .iter()
107                    .map(|p| {
108                        let wp = model.transform_point3(glam::Vec3::from(*p));
109                        Vector::new(wp.x, wp.y, wp.z)
110                    })
111                    .collect();
112
113                let tri_indices: Vec<[u32; 3]> = indices
114                    .chunks(3)
115                    .filter(|c| c.len() == 3)
116                    .map(|c| [c[0], c[1], c[2]])
117                    .collect();
118
119                if tri_indices.is_empty() {
120                    continue;
121                }
122
123                match parry3d::shape::TriMesh::new(verts, tri_indices) {
124                    Ok(trimesh) => {
125                        // Vertices are already in world space: use identity pose.
126                        let identity = Pose::identity();
127                        let Some(intersection) =
128                            trimesh.cast_ray_and_get_normal(&identity, &ray, f32::MAX, true)
129                        else {
130                            continue;
131                        };
132                        let toi = intersection.time_of_impact;
133                        let world_pos = ray_origin + ray_dir * toi;
134                        let normal = intersection.normal;
135
136                        let feature_sub = SubObjectRef::from_feature_id(intersection.feature);
137
138                        let sub_object = if wants_face {
139                            feature_sub
140                        } else if wants_cell {
141                            // Convert surface Face hit to originating cell index.
142                            if let Some(f2c) = vm_cell_map.get(&item.settings.pick_id.0) {
143                                match feature_sub {
144                                    Some(SubObjectRef::Face(face_raw)) => {
145                                        let n_tri = indices.len() / 3;
146                                        let face = if (face_raw as usize) >= n_tri {
147                                            face_raw as usize - n_tri
148                                        } else {
149                                            face_raw as usize
150                                        };
151                                        f2c.get(face).map(|&ci| SubObjectRef::Cell(ci))
152                                    }
153                                    other => other,
154                                }
155                            } else if wants_vertex {
156                                // No cell map for this item; try vertex picking instead.
157                                // Fall through to the vertex branch below by
158                                // re-evaluating with the vertex logic inline.
159                                match feature_sub {
160                                    Some(SubObjectRef::Face(face_raw)) => {
161                                        let n_tri = indices.len() / 3;
162                                        let face = if (face_raw as usize) >= n_tri {
163                                            face_raw as usize - n_tri
164                                        } else {
165                                            face_raw as usize
166                                        };
167                                        if face * 3 + 2 < indices.len() {
168                                            let vis = [
169                                                indices[face * 3] as usize,
170                                                indices[face * 3 + 1] as usize,
171                                                indices[face * 3 + 2] as usize,
172                                            ];
173                                            let (best_vi, _) = vis
174                                                .iter()
175                                                .map(|&i| {
176                                                    let p = model.transform_point3(
177                                                        glam::Vec3::from(positions[i]),
178                                                    );
179                                                    (i, p.distance(world_pos))
180                                                })
181                                                .fold((vis[0], f32::MAX), |acc, (i, d)| {
182                                                    if d < acc.1 { (i, d) } else { acc }
183                                                });
184                                            Some(SubObjectRef::Vertex(best_vi as u32))
185                                        } else {
186                                            None
187                                        }
188                                    }
189                                    other => other,
190                                }
191                            } else {
192                                // No cell map and vertex not wanted; no sub-element.
193                                None
194                            }
195                        } else if wants_vertex {
196                            // Convert face hit to nearest triangle corner.
197                            match feature_sub {
198                                Some(SubObjectRef::Face(face_raw)) => {
199                                    let n_tri = indices.len() / 3;
200                                    let face = if (face_raw as usize) >= n_tri {
201                                        face_raw as usize - n_tri
202                                    } else {
203                                        face_raw as usize
204                                    };
205                                    if face * 3 + 2 < indices.len() {
206                                        let vis = [
207                                            indices[face * 3] as usize,
208                                            indices[face * 3 + 1] as usize,
209                                            indices[face * 3 + 2] as usize,
210                                        ];
211                                        let (best_vi, _) = vis
212                                            .iter()
213                                            .map(|&i| {
214                                                let p = model.transform_point3(glam::Vec3::from(
215                                                    positions[i],
216                                                ));
217                                                (i, p.distance(world_pos))
218                                            })
219                                            .fold((vis[0], f32::MAX), |acc, (i, d)| {
220                                                if d < acc.1 { (i, d) } else { acc }
221                                            });
222                                        Some(SubObjectRef::Vertex(best_vi as u32))
223                                    } else {
224                                        None
225                                    }
226                                }
227                                other => other,
228                            }
229                        } else {
230                            // Object-only: no sub-element.
231                            None
232                        };
233
234                        // Only emit the hit if we produced a meaningful sub-element
235                        // or the caller explicitly asked for object-level hits.
236                        // Without this guard, an EDGE-only mask runs the ray-trimesh
237                        // intersection (because wants_mesh_sub is true) but falls through
238                        // to sub_object=None, producing a spurious object-level hit.
239                        if sub_object.is_some() || wants_object {
240                            #[allow(deprecated)]
241                            let hit = PickHit {
242                                id: item.settings.pick_id.0,
243                                sub_object,
244                                world_pos,
245                                normal,
246                                triangle_index: u32::MAX,
247                                point_index: None,
248                                scalar_value: None,
249                            };
250                            consider(toi, hit);
251                        }
252                    }
253                    Err(e) => {
254                        tracing::warn!(
255                            pick_id = item.settings.pick_id.0,
256                            error = %e,
257                            "TriMesh build failed in renderer.pick()"
258                        );
259                    }
260                }
261            }
262        }
263
264        // 2. Opaque volume mesh cell picks are handled in section 1 above via
265        // vm_cell_map (face_to_cell conversion on surface Face hits).
266
267        // 2c. Scatter-volume object picks. Ray-vs-shape intersection only;
268        // there is no sub-object level for participating media
269        if wants_object {
270            for item in &self.pick_scatter_volume_items {
271                if item.settings.hidden || item.settings.pick_id == PickId::NONE {
272                    continue;
273                }
274                if let Some((t_enter, _)) = crate::scene::scatter_volume::ray_intersect(
275                    &item.volume.shape,
276                    ray_origin,
277                    ray_dir,
278                ) {
279                    let world_pos = ray_origin + ray_dir * t_enter;
280                    let normal = (world_pos
281                        - match item.volume.shape {
282                            crate::scene::scatter_volume::ScatterShape::Box(b) => {
283                                (b.min + b.max) * 0.5
284                            }
285                            crate::scene::scatter_volume::ScatterShape::Sphere {
286                                center, ..
287                            } => glam::Vec3::from(center),
288                        })
289                    .try_normalize()
290                    .unwrap_or(glam::Vec3::Z);
291                    consider(
292                        t_enter,
293                        PickHit::object_hit(item.settings.pick_id.0, world_pos, normal),
294                    );
295                }
296            }
297        }
298
299        // 2b. Interior-inclusive cell picks for volume meshes rendering
300        //     transparently. Items rendering as opaque are handled in section 1
301        //     above via vm_cell_map (face_to_cell on the boundary surface).
302        if wants_cell || wants_object {
303            for item in &self.pick_volume_mesh_items {
304                if item.settings.pick_id == PickId::NONE || item.transparency.is_none() {
305                    continue;
306                }
307                let Some(data) = item.volume_mesh_data.as_deref() else {
308                    continue;
309                };
310                let model = glam::Mat4::from_cols_array_2d(&item.model);
311                if let Some(mut hit) = pick_transparent_volume_mesh_cpu(
312                    ray_origin,
313                    ray_dir,
314                    item.settings.pick_id.0,
315                    model,
316                    data,
317                ) {
318                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
319                    if !wants_cell {
320                        hit.sub_object = None;
321                    }
322                    consider(toi, hit);
323                }
324            }
325        }
326
327        // 3. Point cloud picks (CLOUD_POINT or OBJECT fallback).
328        if wants_cloud || wants_object {
329            for item in &self.pick_point_cloud_items {
330                if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
331                    continue;
332                }
333                let radius_px = item.point_size.max(4.0);
334                if let Some(mut hit) = pick_point_cloud_cpu(
335                    click_pos,
336                    item.settings.pick_id.0,
337                    item,
338                    view_proj,
339                    viewport_size,
340                    radius_px,
341                ) {
342                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
343                    if !wants_cloud {
344                        hit.sub_object = None;
345                    }
346                    consider(toi, hit);
347                }
348            }
349        }
350
351        // 4. Volume voxel picks (VOXEL or OBJECT fallback).
352        let wants_voxel = mask.intersects(PickMask::VOXEL);
353        if wants_voxel || wants_object {
354            for item in &self.pick_volume_items {
355                if item.settings.pick_id == PickId::NONE {
356                    continue;
357                }
358                let Some(vol_data) = item.volume_data.as_deref() else {
359                    continue;
360                };
361                if let Some(mut hit) =
362                    pick_volume_cpu(ray_origin, ray_dir, item.settings.pick_id.0, item, vol_data)
363                {
364                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
365                    if !wants_voxel {
366                        hit.sub_object = None;
367                    }
368                    consider(toi, hit);
369                }
370            }
371        }
372
373        // 5. Gaussian splat picks (SPLAT or OBJECT fallback).
374        if wants_splat || wants_object {
375            for item in &self.pick_splat_items {
376                if item.settings.pick_id == PickId::NONE {
377                    continue;
378                }
379                let Some(gpu_set) = self.resources.gaussian_splat_store.get(item.id.0) else {
380                    continue;
381                };
382                if gpu_set.cpu_positions.is_empty() {
383                    continue;
384                }
385                let model = glam::Mat4::from_cols_array_2d(&item.model);
386                // Derive pick radius from the mean per-splat scale so that a
387                // click anywhere inside the visible disc registers as a hit.
388                let mean_max_scale: f32 = if gpu_set.cpu_scales.is_empty() {
389                    0.05
390                } else {
391                    gpu_set
392                        .cpu_scales
393                        .iter()
394                        .map(|s| s[0].max(s[1]).max(s[2]))
395                        .sum::<f32>()
396                        / gpu_set.cpu_scales.len() as f32
397                };
398                let world_radius = mean_max_scale * 3.0;
399                let center_w = model.transform_point3(glam::Vec3::ZERO);
400                let p0_clip = view_proj * center_w.extend(1.0);
401                let p1_clip = view_proj * (center_w + glam::Vec3::X * world_radius).extend(1.0);
402                let radius_px = if p0_clip.w.abs() > 1e-6 && p1_clip.w.abs() > 1e-6 {
403                    let p0_ndc = glam::Vec2::new(p0_clip.x, p0_clip.y) / p0_clip.w;
404                    let p1_ndc = glam::Vec2::new(p1_clip.x, p1_clip.y) / p1_clip.w;
405                    ((p1_ndc - p0_ndc).length() * 0.5 * viewport_size.x.max(viewport_size.y))
406                        .max(4.0)
407                } else {
408                    world_radius * 100.0
409                };
410                if let Some(mut hit) = pick_gaussian_splat_cpu(
411                    click_pos,
412                    item.settings.pick_id.0,
413                    &gpu_set.cpu_positions,
414                    model,
415                    view_proj,
416                    viewport_size,
417                    radius_px,
418                ) {
419                    // pick_gaussian_splat_cpu returns SubObjectRef::Point; remap to Splat.
420                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
421                    if wants_splat {
422                        if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
423                            hit.sub_object = Some(SubObjectRef::Splat(idx));
424                        }
425                    } else {
426                        hit.sub_object = None;
427                    }
428                    consider(toi, hit);
429                }
430            }
431        }
432
433        // 6. Instance picks (INSTANCE or OBJECT fallback) for glyphs, tensor glyphs, sprites.
434        let wants_instance = mask.intersects(PickMask::INSTANCE);
435        if wants_instance || wants_object {
436            // Convert a world-space radius at a given world position to a pixel threshold.
437            // Using the actual instance centroid rather than the model origin gives a correct
438            // pixel size when instances are offset far from the model's local origin.
439            let instance_radius_px = |world_center: glam::Vec3, world_r: f32| -> f32 {
440                let p0 = view_proj * world_center.extend(1.0);
441                let p1 = view_proj * (world_center + glam::Vec3::X * world_r).extend(1.0);
442                if p0.w.abs() > 1e-6 && p1.w.abs() > 1e-6 {
443                    let n0 = glam::Vec2::new(p0.x, p0.y) / p0.w;
444                    let n1 = glam::Vec2::new(p1.x, p1.y) / p1.w;
445                    ((n1 - n0).length() * 0.5 * viewport_size.x.max(viewport_size.y)).max(4.0)
446                } else {
447                    (world_r * 100.0_f32).max(4.0)
448                }
449            };
450
451            // Glyphs
452            for item in &self.pick_glyph_items {
453                if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
454                    continue;
455                }
456                let model = glam::Mat4::from_cols_array_2d(&item.model);
457                let full_len = if item.scale_by_magnitude && !item.vectors.is_empty() {
458                    let mean_mag = item
459                        .vectors
460                        .iter()
461                        .map(|v| glam::Vec3::from(*v).length())
462                        .sum::<f32>()
463                        / item.vectors.len() as f32;
464                    (mean_mag * item.scale).max(0.01)
465                } else {
466                    item.scale.max(0.01)
467                };
468                // Test against the midpoint of each arrow (base + half-vector) with
469                // world_r = half-length. This prevents the hit circle from extending a full
470                // arrow-length behind the base when the arrow points away from the camera.
471                let has_vecs = item.vectors.len() == item.positions.len();
472                let midpoints: Vec<[f32; 3]> = item
473                    .positions
474                    .iter()
475                    .enumerate()
476                    .map(|(i, pos)| {
477                        if has_vecs {
478                            let p = glam::Vec3::from(*pos);
479                            let v = glam::Vec3::from(item.vectors[i]);
480                            let len = if item.scale_by_magnitude {
481                                v.length() * item.scale
482                            } else {
483                                item.scale
484                            };
485                            (p + v.normalize_or_zero() * len * 0.5).to_array()
486                        } else {
487                            *pos
488                        }
489                    })
490                    .collect();
491                let n = midpoints.len() as f32;
492                let centroid = model.transform_point3(
493                    midpoints
494                        .iter()
495                        .map(|p| glam::Vec3::from(*p))
496                        .sum::<glam::Vec3>()
497                        / n,
498                );
499                let radius_px = instance_radius_px(centroid, full_len * 0.5);
500                if let Some(mut hit) = pick_gaussian_splat_cpu(
501                    click_pos,
502                    item.settings.pick_id.0,
503                    &midpoints,
504                    model,
505                    view_proj,
506                    viewport_size,
507                    radius_px,
508                ) {
509                    // Report the base position, not the midpoint.
510                    if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
511                        if let Some(base) = item.positions.get(idx as usize) {
512                            hit.world_pos = model.transform_point3(glam::Vec3::from(*base));
513                        }
514                        if wants_instance {
515                            hit.sub_object = Some(SubObjectRef::Instance(idx));
516                        } else {
517                            hit.sub_object = None;
518                        }
519                    }
520                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
521                    consider(toi, hit);
522                }
523            }
524
525            // Tensor glyphs
526            for item in &self.pick_tensor_glyph_items {
527                if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
528                    continue;
529                }
530                let model = glam::Mat4::from_cols_array_2d(&item.model);
531                // Use the max eigenvalue across all instances so the largest ellipsoid
532                // is fully covered. Use the centroid of instance positions for an accurate
533                // pixel-size estimate (instances may be far from the model origin).
534                let world_r = if !item.eigenvalues.is_empty() {
535                    let max_ev = item
536                        .eigenvalues
537                        .iter()
538                        .map(|ev| ev[0].abs().max(ev[1].abs()).max(ev[2].abs()))
539                        .fold(0.0_f32, f32::max);
540                    (max_ev * item.scale).max(0.01)
541                } else {
542                    item.scale.max(0.01)
543                };
544                let n = item.positions.len() as f32;
545                let centroid = model.transform_point3(
546                    item.positions
547                        .iter()
548                        .map(|p| glam::Vec3::from(*p))
549                        .sum::<glam::Vec3>()
550                        / n,
551                );
552                let radius_px = instance_radius_px(centroid, world_r);
553                if let Some(mut hit) = pick_gaussian_splat_cpu(
554                    click_pos,
555                    item.settings.pick_id.0,
556                    &item.positions,
557                    model,
558                    view_proj,
559                    viewport_size,
560                    radius_px,
561                ) {
562                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
563                    if wants_instance {
564                        if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
565                            hit.sub_object = Some(SubObjectRef::Instance(idx));
566                        }
567                    } else {
568                        hit.sub_object = None;
569                    }
570                    consider(toi, hit);
571                }
572            }
573
574            // Sprites
575            for item in &self.pick_sprite_items {
576                if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
577                    continue;
578                }
579                let model = glam::Mat4::from_cols_array_2d(&item.model);
580                let radius_px = match item.size_mode {
581                    SpriteSizeMode::ScreenSpace => (item.default_size * 0.5).max(4.0),
582                    SpriteSizeMode::WorldSpace => {
583                        let n = item.positions.len() as f32;
584                        let centroid = model.transform_point3(
585                            item.positions
586                                .iter()
587                                .map(|p| glam::Vec3::from(*p))
588                                .sum::<glam::Vec3>()
589                                / n,
590                        );
591                        instance_radius_px(centroid, (item.default_size * 0.5).max(0.01))
592                    }
593                };
594                if let Some(mut hit) = pick_gaussian_splat_cpu(
595                    click_pos,
596                    item.settings.pick_id.0,
597                    &item.positions,
598                    model,
599                    view_proj,
600                    viewport_size,
601                    radius_px,
602                ) {
603                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
604                    if wants_instance {
605                        if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
606                            hit.sub_object = Some(SubObjectRef::Instance(idx));
607                        }
608                    } else {
609                        hit.sub_object = None;
610                    }
611                    consider(toi, hit);
612                }
613            }
614        }
615
616        // 7. Polyline node picks (POLY_NODE, STRIP, or OBJECT fallback).
617        let wants_poly_node = mask.intersects(PickMask::POLY_NODE);
618        let wants_strip = mask.intersects(PickMask::STRIP);
619        if wants_poly_node || wants_strip || wants_object {
620            for item in &self.pick_polyline_items {
621                if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
622                    continue;
623                }
624                let radius_px = (item.line_width + 4.0).max(8.0);
625                if let Some(mut hit) = pick_gaussian_splat_cpu(
626                    click_pos,
627                    item.settings.pick_id.0,
628                    &item.positions,
629                    glam::Mat4::IDENTITY,
630                    view_proj,
631                    viewport_size,
632                    radius_px,
633                ) {
634                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
635                    if wants_poly_node {
636                        // sub_object is already SubObjectRef::Point(node_index)
637                    } else if wants_strip {
638                        if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
639                            hit.sub_object = Some(SubObjectRef::Strip(strip_for_node(
640                                idx,
641                                &item.strip_lengths,
642                            )));
643                        }
644                    } else {
645                        hit.sub_object = None;
646                    }
647                    consider(toi, hit);
648                }
649            }
650        }
651
652        // 8. Polyline segment picks (SEGMENT, STRIP, or OBJECT fallback).
653        // Uses screen-space distance from the click to the full segment line so
654        // clicking anywhere along a segment registers, not just near the midpoint.
655        let wants_segment = mask.intersects(PickMask::SEGMENT);
656        if wants_segment || wants_strip || wants_object {
657            for item in &self.pick_polyline_items {
658                if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
659                    continue;
660                }
661                // Half the visual line width plus a few pixels of slack.
662                let threshold_px = (item.line_width / 2.0 + 4.0).max(4.0);
663                let Some((seg_idx, world_pos)) = pick_closest_polyline_segment(
664                    click_pos,
665                    viewport_size,
666                    view_proj,
667                    &item.positions,
668                    &item.strip_lengths,
669                    threshold_px,
670                ) else {
671                    continue;
672                };
673                let toi = (world_pos - ray_origin).dot(ray_dir).max(0.0);
674                let sub_object = if wants_segment {
675                    Some(SubObjectRef::Segment(seg_idx))
676                } else if wants_strip {
677                    Some(SubObjectRef::Strip(strip_for_segment(
678                        seg_idx,
679                        &item.strip_lengths,
680                    )))
681                } else {
682                    None
683                };
684                #[allow(deprecated)]
685                let hit = PickHit {
686                    id: item.settings.pick_id.0,
687                    sub_object,
688                    world_pos,
689                    normal: glam::Vec3::Z,
690                    triangle_index: u32::MAX,
691                    point_index: None,
692                    scalar_value: None,
693                };
694                consider(toi, hit);
695            }
696        }
697
698        // 9. Streamtube / tube / ribbon picks (POLY_NODE, SEGMENT, STRIP, or OBJECT).
699        // Streamtube / tube: screen-space closest-segment test against each cylinder
700        //     axis (both endpoints projected), not just the midpoint.
701        //   Ribbon: ray-triangle intersection against the reconstructed swept quad
702        //     using the parallel-transport lateral frame.
703        //   POLY_NODE: control points are point-like sub-elements (pick_gaussian_splat_cpu).
704        if wants_poly_node || wants_segment || wants_strip || wants_object {
705            // Convert a world-space radius at a reference point to a screen-pixel threshold.
706            let world_r_to_px = |ref_world: glam::Vec3, world_r: f32| -> f32 {
707                let p0 = view_proj * ref_world.extend(1.0);
708                let p1 = view_proj * (ref_world + glam::Vec3::X * world_r).extend(1.0);
709                if p0.w.abs() > 1e-6 && p1.w.abs() > 1e-6 {
710                    let n0 = glam::Vec2::new(p0.x, p0.y) / p0.w;
711                    let n1 = glam::Vec2::new(p1.x, p1.y) / p1.w;
712                    ((n1 - n0).length() * 0.5 * viewport_size.x.max(viewport_size.y)).max(4.0)
713                } else {
714                    (world_r * 100.0_f32).max(4.0)
715                }
716            };
717
718            // POLY_NODE pass: nearest control point, promoted to Strip/Object as needed.
719            if wants_poly_node || wants_strip || wants_object {
720                for item in &self.pick_streamtube_items {
721                    if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
722                        continue;
723                    }
724                    let ref_pos = glam::Vec3::from(item.positions[0]);
725                    let radius_px = world_r_to_px(ref_pos, item.radius.max(0.01)).max(8.0);
726                    if let Some(mut hit) = pick_gaussian_splat_cpu(
727                        click_pos,
728                        item.settings.pick_id.0,
729                        &item.positions,
730                        glam::Mat4::IDENTITY,
731                        view_proj,
732                        viewport_size,
733                        radius_px,
734                    ) {
735                        let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
736                        if wants_poly_node {
737                            // sub_object is already SubObjectRef::Point(node_index)
738                        } else if wants_strip {
739                            if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
740                                hit.sub_object = Some(SubObjectRef::Strip(strip_for_node(
741                                    idx,
742                                    &item.strip_lengths,
743                                )));
744                            }
745                        } else {
746                            hit.sub_object = None;
747                        }
748                        consider(toi, hit);
749                    }
750                }
751                for item in &self.pick_tube_items {
752                    if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
753                        continue;
754                    }
755                    let ref_pos = glam::Vec3::from(item.positions[0]);
756                    let max_r = item
757                        .radius_attribute
758                        .as_ref()
759                        .and_then(|ra| ra.iter().copied().reduce(f32::max))
760                        .unwrap_or(0.0)
761                        .max(item.radius)
762                        .max(0.01);
763                    let radius_px = world_r_to_px(ref_pos, max_r).max(8.0);
764                    if let Some(mut hit) = pick_gaussian_splat_cpu(
765                        click_pos,
766                        item.settings.pick_id.0,
767                        &item.positions,
768                        glam::Mat4::IDENTITY,
769                        view_proj,
770                        viewport_size,
771                        radius_px,
772                    ) {
773                        let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
774                        if wants_poly_node {
775                            // sub_object is already SubObjectRef::Point(node_index)
776                        } else if wants_strip {
777                            if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
778                                hit.sub_object = Some(SubObjectRef::Strip(strip_for_node(
779                                    idx,
780                                    &item.strip_lengths,
781                                )));
782                            }
783                        } else {
784                            hit.sub_object = None;
785                        }
786                        consider(toi, hit);
787                    }
788                }
789                for item in &self.pick_ribbon_items {
790                    if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
791                        continue;
792                    }
793                    let ref_pos = glam::Vec3::from(item.positions[0]);
794                    let radius_px = world_r_to_px(ref_pos, item.width * 0.5).max(8.0);
795                    if let Some(mut hit) = pick_gaussian_splat_cpu(
796                        click_pos,
797                        item.settings.pick_id.0,
798                        &item.positions,
799                        glam::Mat4::IDENTITY,
800                        view_proj,
801                        viewport_size,
802                        radius_px,
803                    ) {
804                        let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
805                        if wants_poly_node {
806                            // sub_object is already SubObjectRef::Point(node_index)
807                        } else if wants_strip {
808                            if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
809                                hit.sub_object = Some(SubObjectRef::Strip(strip_for_node(
810                                    idx,
811                                    &item.strip_lengths,
812                                )));
813                            }
814                        } else {
815                            hit.sub_object = None;
816                        }
817                        consider(toi, hit);
818                    }
819                }
820            }
821
822            // SEGMENT / STRIP / OBJECT pass using full geometric tests.
823            if wants_segment || wants_strip || wants_object {
824                // Streamtube: project each cylinder axis segment to screen and find the
825                // closest point along the full segment (not just the midpoint).
826                for item in &self.pick_streamtube_items {
827                    if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
828                        continue;
829                    }
830                    let ref_pos = glam::Vec3::from(item.positions[0]);
831                    let threshold_px = world_r_to_px(ref_pos, item.radius.max(0.01));
832                    let Some((seg_idx, world_pos)) = pick_closest_polyline_segment(
833                        click_pos,
834                        viewport_size,
835                        view_proj,
836                        &item.positions,
837                        &item.strip_lengths,
838                        threshold_px,
839                    ) else {
840                        continue;
841                    };
842                    let toi = (world_pos - ray_origin).dot(ray_dir).max(0.0);
843                    let sub_object = if wants_segment {
844                        Some(SubObjectRef::Segment(seg_idx))
845                    } else if wants_strip {
846                        Some(SubObjectRef::Strip(strip_for_segment(
847                            seg_idx,
848                            &item.strip_lengths,
849                        )))
850                    } else {
851                        None
852                    };
853                    #[allow(deprecated)]
854                    consider(
855                        toi,
856                        PickHit {
857                            id: item.settings.pick_id.0,
858                            sub_object,
859                            world_pos,
860                            normal: glam::Vec3::Z,
861                            triangle_index: u32::MAX,
862                            point_index: None,
863                            scalar_value: None,
864                        },
865                    );
866                }
867
868                // Tube: same as streamtube; uses the conservative max of uniform and
869                // per-point radii for the screen-space threshold.
870                for item in &self.pick_tube_items {
871                    if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
872                        continue;
873                    }
874                    let ref_pos = glam::Vec3::from(item.positions[0]);
875                    let max_r = item
876                        .radius_attribute
877                        .as_ref()
878                        .and_then(|ra| ra.iter().copied().reduce(f32::max))
879                        .unwrap_or(0.0)
880                        .max(item.radius)
881                        .max(0.01);
882                    let threshold_px = world_r_to_px(ref_pos, max_r);
883                    let Some((seg_idx, world_pos)) = pick_closest_polyline_segment(
884                        click_pos,
885                        viewport_size,
886                        view_proj,
887                        &item.positions,
888                        &item.strip_lengths,
889                        threshold_px,
890                    ) else {
891                        continue;
892                    };
893                    let toi = (world_pos - ray_origin).dot(ray_dir).max(0.0);
894                    let sub_object = if wants_segment {
895                        Some(SubObjectRef::Segment(seg_idx))
896                    } else if wants_strip {
897                        Some(SubObjectRef::Strip(strip_for_segment(
898                            seg_idx,
899                            &item.strip_lengths,
900                        )))
901                    } else {
902                        None
903                    };
904                    #[allow(deprecated)]
905                    consider(
906                        toi,
907                        PickHit {
908                            id: item.settings.pick_id.0,
909                            sub_object,
910                            world_pos,
911                            normal: glam::Vec3::Z,
912                            triangle_index: u32::MAX,
913                            point_index: None,
914                            scalar_value: None,
915                        },
916                    );
917                }
918
919                // Ribbon: reconstruct the swept quad per segment (parallel-transport
920                // lateral frame) and test the ray against both triangles of each quad.
921                for item in &self.pick_ribbon_items {
922                    if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
923                        continue;
924                    }
925                    let frames = ribbon_lateral_frames(
926                        &item.positions,
927                        &item.strip_lengths,
928                        item.width,
929                        item.width_attribute.as_deref(),
930                        item.twist_attribute.as_deref(),
931                    );
932
933                    let single;
934                    let strips: &[u32] = if item.strip_lengths.is_empty() {
935                        single = [item.positions.len() as u32];
936                        &single
937                    } else {
938                        &item.strip_lengths
939                    };
940
941                    let mut best_t = f32::MAX;
942                    let mut best_seg: Option<(u32, glam::Vec3)> = None;
943                    let mut node_off = 0usize;
944                    let mut seg_off = 0u32;
945
946                    for &slen in strips {
947                        let slen = slen as usize;
948                        for k in 0..slen.saturating_sub(1) {
949                            let ia = node_off + k;
950                            let ib = node_off + k + 1;
951                            let pa = glam::Vec3::from(item.positions[ia]);
952                            let pb = glam::Vec3::from(item.positions[ib]);
953                            let (ua, wa) = frames[ia];
954                            let (ub, wb) = frames[ib];
955                            // Quad corners: c0/c1 at segment start, c2/c3 at end.
956                            let c0 = pa + ua * wa; // left  at a
957                            let c1 = pa - ua * wa; // right at a
958                            let c2 = pb + ub * wb; // left  at b
959                            let c3 = pb - ub * wb; // right at b
960                            // Test 2 triangles, both front and back faces.
961                            let t = ray_triangle(ray_origin, ray_dir, c0, c1, c2)
962                                .or_else(|| ray_triangle(ray_origin, ray_dir, c1, c3, c2))
963                                .or_else(|| ray_triangle(ray_origin, ray_dir, c2, c1, c0))
964                                .or_else(|| ray_triangle(ray_origin, ray_dir, c2, c3, c1));
965                            if let Some(t) = t {
966                                if t < best_t {
967                                    best_t = t;
968                                    best_seg = Some((seg_off + k as u32, ray_origin + ray_dir * t));
969                                }
970                            }
971                        }
972                        seg_off += slen.saturating_sub(1) as u32;
973                        node_off += slen;
974                    }
975
976                    if let Some((seg_idx, world_pos)) = best_seg {
977                        let sub_object = if wants_segment {
978                            Some(SubObjectRef::Segment(seg_idx))
979                        } else if wants_strip {
980                            Some(SubObjectRef::Strip(strip_for_segment(
981                                seg_idx,
982                                &item.strip_lengths,
983                            )))
984                        } else {
985                            None
986                        };
987                        #[allow(deprecated)]
988                        consider(
989                            best_t,
990                            PickHit {
991                                id: item.settings.pick_id.0,
992                                sub_object,
993                                world_pos,
994                                normal: glam::Vec3::Z,
995                                triangle_index: u32::MAX,
996                                point_index: None,
997                                scalar_value: None,
998                            },
999                        );
1000                    }
1001                }
1002            }
1003        }
1004
1005        // 10. Image slice / volume surface slice / screen image object picks (OBJECT only).
1006        if wants_object {
1007            // Image slice: axis-aligned quad ray intersection.
1008            for item in &self.pick_image_slice_items {
1009                if item.settings.pick_id == PickId::NONE {
1010                    continue;
1011                }
1012                let [bmin, bmax] = [item.bbox_min, item.bbox_max];
1013                let t = item.offset;
1014                // Plane normal and position along the axis.
1015                let (axis_idx, plane_pos) = match item.axis {
1016                    SliceAxis::X => (0usize, bmin[0] + t * (bmax[0] - bmin[0])),
1017                    SliceAxis::Y => (1usize, bmin[1] + t * (bmax[1] - bmin[1])),
1018                    SliceAxis::Z => (2usize, bmin[2] + t * (bmax[2] - bmin[2])),
1019                };
1020                let plane_n = {
1021                    let mut n = glam::Vec3::ZERO;
1022                    n[axis_idx] = 1.0;
1023                    n
1024                };
1025                let denom = plane_n.dot(ray_dir);
1026                if denom.abs() < 1e-6 {
1027                    continue;
1028                }
1029                let toi = (plane_pos - ray_origin[axis_idx]) / denom;
1030                if toi <= 0.0 {
1031                    continue;
1032                }
1033                let hit_pos = ray_origin + ray_dir * toi;
1034                // Check that the hit is within the slice quad's other two dimensions.
1035                let in_bounds = (0..3)
1036                    .filter(|&i| i != axis_idx)
1037                    .all(|i| hit_pos[i] >= bmin[i] - 1e-4 && hit_pos[i] <= bmax[i] + 1e-4);
1038                if in_bounds {
1039                    #[allow(deprecated)]
1040                    consider(
1041                        toi,
1042                        PickHit {
1043                            id: item.settings.pick_id.0,
1044                            sub_object: None,
1045                            world_pos: hit_pos,
1046                            normal: plane_n,
1047                            triangle_index: u32::MAX,
1048                            point_index: None,
1049                            scalar_value: None,
1050                        },
1051                    );
1052                }
1053            }
1054
1055            // Volume surface slice: ray/mesh intersection via mesh_store CPU data.
1056            for item in &self.pick_volume_surface_slice_items {
1057                if item.settings.pick_id == PickId::NONE {
1058                    continue;
1059                }
1060                let Some(mesh) = self.resources.mesh_store.get(item.mesh_id) else {
1061                    continue;
1062                };
1063                let (Some(positions), Some(indices)) = (&mesh.cpu_positions, &mesh.cpu_indices)
1064                else {
1065                    continue;
1066                };
1067                let model = glam::Mat4::from_cols_array_2d(&item.model);
1068                let verts: Vec<parry3d::math::Vector> = positions
1069                    .iter()
1070                    .map(|p| {
1071                        let wp = model.transform_point3(glam::Vec3::from(*p));
1072                        parry3d::math::Vector::new(wp.x, wp.y, wp.z)
1073                    })
1074                    .collect();
1075                let tri_indices: Vec<[u32; 3]> = indices
1076                    .chunks(3)
1077                    .filter(|c| c.len() == 3)
1078                    .map(|c| [c[0], c[1], c[2]])
1079                    .collect();
1080                if tri_indices.is_empty() {
1081                    continue;
1082                }
1083                let ray = parry3d::query::Ray::new(
1084                    parry3d::math::Vector::new(ray_origin.x, ray_origin.y, ray_origin.z),
1085                    parry3d::math::Vector::new(ray_dir.x, ray_dir.y, ray_dir.z),
1086                );
1087                if let Ok(trimesh) = parry3d::shape::TriMesh::new(verts, tri_indices) {
1088                    use parry3d::query::RayCast;
1089                    if let Some(hit) = trimesh.cast_ray_and_get_normal(
1090                        &parry3d::math::Pose::identity(),
1091                        &ray,
1092                        f32::MAX,
1093                        true,
1094                    ) {
1095                        let world_pos = ray_origin + ray_dir * hit.time_of_impact;
1096                        let n = hit.normal;
1097                        #[allow(deprecated)]
1098                        consider(
1099                            hit.time_of_impact,
1100                            PickHit {
1101                                id: item.settings.pick_id.0,
1102                                sub_object: None,
1103                                world_pos,
1104                                normal: glam::Vec3::new(n.x, n.y, n.z),
1105                                triangle_index: u32::MAX,
1106                                point_index: None,
1107                                scalar_value: None,
1108                            },
1109                        );
1110                    }
1111                }
1112            }
1113
1114            // Screen image: screen-space rect test. toi=0 so these win over any 3D hit.
1115            for item in &self.pick_screen_image_items {
1116                if item.settings.pick_id == PickId::NONE || item.width == 0 || item.height == 0 {
1117                    continue;
1118                }
1119                let img_w = item.width as f32 * item.scale;
1120                let img_h = item.height as f32 * item.scale;
1121                let (sx, sy) = match item.anchor {
1122                    ImageAnchor::TopLeft => (0.0, 0.0),
1123                    ImageAnchor::TopRight => (viewport_size.x - img_w, 0.0),
1124                    ImageAnchor::BottomLeft => (0.0, viewport_size.y - img_h),
1125                    ImageAnchor::BottomRight => (viewport_size.x - img_w, viewport_size.y - img_h),
1126                    ImageAnchor::Center => (
1127                        (viewport_size.x - img_w) * 0.5,
1128                        (viewport_size.y - img_h) * 0.5,
1129                    ),
1130                };
1131                if click_pos.x >= sx
1132                    && click_pos.x <= sx + img_w
1133                    && click_pos.y >= sy
1134                    && click_pos.y <= sy + img_h
1135                {
1136                    // No meaningful 3D position; place the hit at the near-plane.
1137                    let world_pos = ray_origin + ray_dir * 0.001;
1138                    #[allow(deprecated)]
1139                    consider(
1140                        0.0,
1141                        PickHit {
1142                            id: item.settings.pick_id.0,
1143                            sub_object: None,
1144                            world_pos,
1145                            normal: -ray_dir,
1146                            triangle_index: u32::MAX,
1147                            point_index: None,
1148                            scalar_value: None,
1149                        },
1150                    );
1151                }
1152            }
1153        }
1154
1155        // 11. GPU implicit surface picks (OBJECT only -- no sub-element model).
1156        if wants_object {
1157            for item in &self.pick_implicit_items {
1158                if let Some((toi, world_pos)) = pick_implicit_sdf(ray_origin, ray_dir, item) {
1159                    #[allow(deprecated)]
1160                    consider(
1161                        toi,
1162                        PickHit {
1163                            id: item.id,
1164                            sub_object: None,
1165                            world_pos,
1166                            normal: glam::Vec3::Z,
1167                            triangle_index: u32::MAX,
1168                            point_index: None,
1169                            scalar_value: None,
1170                        },
1171                    );
1172                }
1173            }
1174        }
1175
1176        // 12. GPU marching cubes surface picks (OBJECT only).
1177        if wants_object {
1178            for item in &self.pick_mc_items {
1179                if let Some((toi, world_pos)) = pick_mc_volume(ray_origin, ray_dir, item) {
1180                    #[allow(deprecated)]
1181                    consider(
1182                        toi,
1183                        PickHit {
1184                            id: item.id,
1185                            sub_object: None,
1186                            world_pos,
1187                            normal: glam::Vec3::Z,
1188                            triangle_index: u32::MAX,
1189                            point_index: None,
1190                            scalar_value: None,
1191                        },
1192                    );
1193                }
1194            }
1195        }
1196
1197        // Consult registered item-type plugins after the built-in pickers.
1198        // Each plugin returns its own closest hit; the router compares
1199        // by world-space ray t against the running best.
1200        if !self.item_type_plugins.is_empty() {
1201            let plugin_ray = crate::plugin_api::PickRay {
1202                origin: ray_origin,
1203                direction: ray_dir,
1204            };
1205            for plugin in self.item_type_plugins.values() {
1206                if let Some((t, hit)) = plugin.pick(&plugin_ray) {
1207                    consider(t, hit);
1208                }
1209            }
1210        }
1211
1212        best.map(|(_, hit)| hit)
1213    }
1214}