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::select::pick_mask::PickMask,
35    ) -> Option<crate::interaction::query::picking::PickHit> {
36        use crate::interaction::query::picking::{
37            PickHit, pick_gaussian_splat_cpu, pick_point_cloud_cpu,
38            pick_transparent_volume_mesh_cpu, pick_volume_cpu, screen_to_ray,
39        };
40        use crate::interaction::select::pick_mask::PickMask;
41        use crate::interaction::select::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.content.gaussian_splat_store.get(item.source)
380                else {
381                    continue;
382                };
383                if gpu_set.cpu_positions.is_empty() {
384                    continue;
385                }
386                let model = glam::Mat4::from_cols_array_2d(&item.model);
387                // Derive pick radius from the mean per-splat scale so that a
388                // click anywhere inside the visible disc registers as a hit.
389                let mean_max_scale: f32 = if gpu_set.cpu_scales.is_empty() {
390                    0.05
391                } else {
392                    gpu_set
393                        .cpu_scales
394                        .iter()
395                        .map(|s| s[0].max(s[1]).max(s[2]))
396                        .sum::<f32>()
397                        / gpu_set.cpu_scales.len() as f32
398                };
399                let world_radius = mean_max_scale * 3.0;
400                let center_w = model.transform_point3(glam::Vec3::ZERO);
401                let p0_clip = view_proj * center_w.extend(1.0);
402                let p1_clip = view_proj * (center_w + glam::Vec3::X * world_radius).extend(1.0);
403                let radius_px = if p0_clip.w.abs() > 1e-6 && p1_clip.w.abs() > 1e-6 {
404                    let p0_ndc = glam::Vec2::new(p0_clip.x, p0_clip.y) / p0_clip.w;
405                    let p1_ndc = glam::Vec2::new(p1_clip.x, p1_clip.y) / p1_clip.w;
406                    ((p1_ndc - p0_ndc).length() * 0.5 * viewport_size.x.max(viewport_size.y))
407                        .max(4.0)
408                } else {
409                    world_radius * 100.0
410                };
411                if let Some(mut hit) = pick_gaussian_splat_cpu(
412                    click_pos,
413                    item.settings.pick_id.0,
414                    &gpu_set.cpu_positions,
415                    model,
416                    view_proj,
417                    viewport_size,
418                    radius_px,
419                ) {
420                    // pick_gaussian_splat_cpu returns SubObjectRef::Point; remap to Splat.
421                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
422                    if wants_splat {
423                        if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
424                            hit.sub_object = Some(SubObjectRef::Splat(idx));
425                        }
426                    } else {
427                        hit.sub_object = None;
428                    }
429                    consider(toi, hit);
430                }
431            }
432        }
433
434        // 6. Instance picks (INSTANCE or OBJECT fallback) for glyphs, tensor glyphs, sprites.
435        let wants_instance = mask.intersects(PickMask::INSTANCE);
436        if wants_instance || wants_object {
437            // Convert a world-space radius at a given world position to a pixel threshold.
438            // Using the actual instance centroid rather than the model origin gives a correct
439            // pixel size when instances are offset far from the model's local origin.
440            let instance_radius_px = |world_center: glam::Vec3, world_r: f32| -> f32 {
441                let p0 = view_proj * world_center.extend(1.0);
442                let p1 = view_proj * (world_center + glam::Vec3::X * world_r).extend(1.0);
443                if p0.w.abs() > 1e-6 && p1.w.abs() > 1e-6 {
444                    let n0 = glam::Vec2::new(p0.x, p0.y) / p0.w;
445                    let n1 = glam::Vec2::new(p1.x, p1.y) / p1.w;
446                    ((n1 - n0).length() * 0.5 * viewport_size.x.max(viewport_size.y)).max(4.0)
447                } else {
448                    (world_r * 100.0_f32).max(4.0)
449                }
450            };
451
452            // Glyphs
453            for item in &self.pick_glyph_items {
454                if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
455                    continue;
456                }
457                let model = glam::Mat4::from_cols_array_2d(&item.model);
458                let full_len = if item.scale_by_magnitude && !item.vectors.is_empty() {
459                    let mean_mag = item
460                        .vectors
461                        .iter()
462                        .map(|v| glam::Vec3::from(*v).length())
463                        .sum::<f32>()
464                        / item.vectors.len() as f32;
465                    (mean_mag * item.scale).max(0.01)
466                } else {
467                    item.scale.max(0.01)
468                };
469                // Test against the midpoint of each arrow (base + half-vector) with
470                // world_r = half-length. This prevents the hit circle from extending a full
471                // arrow-length behind the base when the arrow points away from the camera.
472                let has_vecs = item.vectors.len() == item.positions.len();
473                let midpoints: Vec<[f32; 3]> = item
474                    .positions
475                    .iter()
476                    .enumerate()
477                    .map(|(i, pos)| {
478                        if has_vecs {
479                            let p = glam::Vec3::from(*pos);
480                            let v = glam::Vec3::from(item.vectors[i]);
481                            let len = if item.scale_by_magnitude {
482                                v.length() * item.scale
483                            } else {
484                                item.scale
485                            };
486                            (p + v.normalize_or_zero() * len * 0.5).to_array()
487                        } else {
488                            *pos
489                        }
490                    })
491                    .collect();
492                let n = midpoints.len() as f32;
493                let centroid = model.transform_point3(
494                    midpoints
495                        .iter()
496                        .map(|p| glam::Vec3::from(*p))
497                        .sum::<glam::Vec3>()
498                        / n,
499                );
500                let radius_px = instance_radius_px(centroid, full_len * 0.5);
501                if let Some(mut hit) = pick_gaussian_splat_cpu(
502                    click_pos,
503                    item.settings.pick_id.0,
504                    &midpoints,
505                    model,
506                    view_proj,
507                    viewport_size,
508                    radius_px,
509                ) {
510                    // Report the base position, not the midpoint.
511                    if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
512                        if let Some(base) = item.positions.get(idx as usize) {
513                            hit.world_pos = model.transform_point3(glam::Vec3::from(*base));
514                        }
515                        if wants_instance {
516                            hit.sub_object = Some(SubObjectRef::Instance(idx));
517                        } else {
518                            hit.sub_object = None;
519                        }
520                    }
521                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
522                    consider(toi, hit);
523                }
524            }
525
526            // Tensor glyphs
527            for item in &self.pick_tensor_glyph_items {
528                if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
529                    continue;
530                }
531                let model = glam::Mat4::from_cols_array_2d(&item.model);
532                // Use the max eigenvalue across all instances so the largest ellipsoid
533                // is fully covered. Use the centroid of instance positions for an accurate
534                // pixel-size estimate (instances may be far from the model origin).
535                let world_r = if !item.eigenvalues.is_empty() {
536                    let max_ev = item
537                        .eigenvalues
538                        .iter()
539                        .map(|ev| ev[0].abs().max(ev[1].abs()).max(ev[2].abs()))
540                        .fold(0.0_f32, f32::max);
541                    (max_ev * item.scale).max(0.01)
542                } else {
543                    item.scale.max(0.01)
544                };
545                let n = item.positions.len() as f32;
546                let centroid = model.transform_point3(
547                    item.positions
548                        .iter()
549                        .map(|p| glam::Vec3::from(*p))
550                        .sum::<glam::Vec3>()
551                        / n,
552                );
553                let radius_px = instance_radius_px(centroid, world_r);
554                if let Some(mut hit) = pick_gaussian_splat_cpu(
555                    click_pos,
556                    item.settings.pick_id.0,
557                    &item.positions,
558                    model,
559                    view_proj,
560                    viewport_size,
561                    radius_px,
562                ) {
563                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
564                    if wants_instance {
565                        if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
566                            hit.sub_object = Some(SubObjectRef::Instance(idx));
567                        }
568                    } else {
569                        hit.sub_object = None;
570                    }
571                    consider(toi, hit);
572                }
573            }
574
575            // Sprites
576            for item in &self.pick_sprite_items {
577                if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
578                    continue;
579                }
580                let model = glam::Mat4::from_cols_array_2d(&item.model);
581                let radius_px = match item.size_mode {
582                    SpriteSizeMode::ScreenSpace => (item.default_size * 0.5).max(4.0),
583                    SpriteSizeMode::WorldSpace => {
584                        let n = item.positions.len() as f32;
585                        let centroid = model.transform_point3(
586                            item.positions
587                                .iter()
588                                .map(|p| glam::Vec3::from(*p))
589                                .sum::<glam::Vec3>()
590                                / n,
591                        );
592                        instance_radius_px(centroid, (item.default_size * 0.5).max(0.01))
593                    }
594                };
595                if let Some(mut hit) = pick_gaussian_splat_cpu(
596                    click_pos,
597                    item.settings.pick_id.0,
598                    &item.positions,
599                    model,
600                    view_proj,
601                    viewport_size,
602                    radius_px,
603                ) {
604                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
605                    if wants_instance {
606                        if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
607                            hit.sub_object = Some(SubObjectRef::Instance(idx));
608                        }
609                    } else {
610                        hit.sub_object = None;
611                    }
612                    consider(toi, hit);
613                }
614            }
615        }
616
617        // 7. Polyline node picks (POLY_NODE, STRIP, or OBJECT fallback).
618        let wants_poly_node = mask.intersects(PickMask::POLY_NODE);
619        let wants_strip = mask.intersects(PickMask::STRIP);
620        if wants_poly_node || wants_strip || wants_object {
621            for item in &self.pick_polyline_items {
622                if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
623                    continue;
624                }
625                let radius_px = (item.line_width + 4.0).max(8.0);
626                if let Some(mut hit) = pick_gaussian_splat_cpu(
627                    click_pos,
628                    item.settings.pick_id.0,
629                    &item.positions,
630                    glam::Mat4::IDENTITY,
631                    view_proj,
632                    viewport_size,
633                    radius_px,
634                ) {
635                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
636                    if wants_poly_node {
637                        // sub_object is already SubObjectRef::Point(node_index)
638                    } else if wants_strip {
639                        if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
640                            hit.sub_object = Some(SubObjectRef::Strip(strip_for_node(
641                                idx,
642                                &item.strip_lengths,
643                            )));
644                        }
645                    } else {
646                        hit.sub_object = None;
647                    }
648                    consider(toi, hit);
649                }
650            }
651        }
652
653        // 8. Polyline segment picks (SEGMENT, STRIP, or OBJECT fallback).
654        // Uses screen-space distance from the click to the full segment line so
655        // clicking anywhere along a segment registers, not just near the midpoint.
656        let wants_segment = mask.intersects(PickMask::SEGMENT);
657        if wants_segment || wants_strip || wants_object {
658            for item in &self.pick_polyline_items {
659                if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
660                    continue;
661                }
662                // Half the visual line width plus a few pixels of slack.
663                let threshold_px = (item.line_width / 2.0 + 4.0).max(4.0);
664                let Some((seg_idx, world_pos)) = pick_closest_polyline_segment(
665                    click_pos,
666                    viewport_size,
667                    view_proj,
668                    &item.positions,
669                    &item.strip_lengths,
670                    threshold_px,
671                ) else {
672                    continue;
673                };
674                let toi = (world_pos - ray_origin).dot(ray_dir).max(0.0);
675                let sub_object = if wants_segment {
676                    Some(SubObjectRef::Segment(seg_idx))
677                } else if wants_strip {
678                    Some(SubObjectRef::Strip(strip_for_segment(
679                        seg_idx,
680                        &item.strip_lengths,
681                    )))
682                } else {
683                    None
684                };
685                #[allow(deprecated)]
686                let hit = PickHit {
687                    id: item.settings.pick_id.0,
688                    sub_object,
689                    world_pos,
690                    normal: glam::Vec3::Z,
691                    triangle_index: u32::MAX,
692                    point_index: None,
693                    scalar_value: None,
694                };
695                consider(toi, hit);
696            }
697        }
698
699        // 9. Streamtube / tube / ribbon picks (POLY_NODE, SEGMENT, STRIP, or OBJECT).
700        // Streamtube / tube: screen-space closest-segment test against each cylinder
701        //     axis (both endpoints projected), not just the midpoint.
702        //   Ribbon: ray-triangle intersection against the reconstructed swept quad
703        //     using the parallel-transport lateral frame.
704        //   POLY_NODE: control points are point-like sub-elements (pick_gaussian_splat_cpu).
705        if wants_poly_node || wants_segment || wants_strip || wants_object {
706            // Convert a world-space radius at a reference point to a screen-pixel threshold.
707            let world_r_to_px = |ref_world: glam::Vec3, world_r: f32| -> f32 {
708                let p0 = view_proj * ref_world.extend(1.0);
709                let p1 = view_proj * (ref_world + glam::Vec3::X * world_r).extend(1.0);
710                if p0.w.abs() > 1e-6 && p1.w.abs() > 1e-6 {
711                    let n0 = glam::Vec2::new(p0.x, p0.y) / p0.w;
712                    let n1 = glam::Vec2::new(p1.x, p1.y) / p1.w;
713                    ((n1 - n0).length() * 0.5 * viewport_size.x.max(viewport_size.y)).max(4.0)
714                } else {
715                    (world_r * 100.0_f32).max(4.0)
716                }
717            };
718
719            // POLY_NODE pass: nearest control point, promoted to Strip/Object as needed.
720            if wants_poly_node || wants_strip || wants_object {
721                for item in &self.pick_streamtube_items {
722                    if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
723                        continue;
724                    }
725                    let ref_pos = glam::Vec3::from(item.positions[0]);
726                    let radius_px = world_r_to_px(ref_pos, item.radius.max(0.01)).max(8.0);
727                    if let Some(mut hit) = pick_gaussian_splat_cpu(
728                        click_pos,
729                        item.settings.pick_id.0,
730                        &item.positions,
731                        glam::Mat4::IDENTITY,
732                        view_proj,
733                        viewport_size,
734                        radius_px,
735                    ) {
736                        let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
737                        if wants_poly_node {
738                            // sub_object is already SubObjectRef::Point(node_index)
739                        } else if wants_strip {
740                            if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
741                                hit.sub_object = Some(SubObjectRef::Strip(strip_for_node(
742                                    idx,
743                                    &item.strip_lengths,
744                                )));
745                            }
746                        } else {
747                            hit.sub_object = None;
748                        }
749                        consider(toi, hit);
750                    }
751                }
752                for item in &self.pick_tube_items {
753                    if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
754                        continue;
755                    }
756                    let ref_pos = glam::Vec3::from(item.positions[0]);
757                    let max_r = item
758                        .radius_attribute
759                        .as_ref()
760                        .and_then(|ra| ra.iter().copied().reduce(f32::max))
761                        .unwrap_or(0.0)
762                        .max(item.radius)
763                        .max(0.01);
764                    let radius_px = world_r_to_px(ref_pos, max_r).max(8.0);
765                    if let Some(mut hit) = pick_gaussian_splat_cpu(
766                        click_pos,
767                        item.settings.pick_id.0,
768                        &item.positions,
769                        glam::Mat4::IDENTITY,
770                        view_proj,
771                        viewport_size,
772                        radius_px,
773                    ) {
774                        let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
775                        if wants_poly_node {
776                            // sub_object is already SubObjectRef::Point(node_index)
777                        } else if wants_strip {
778                            if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
779                                hit.sub_object = Some(SubObjectRef::Strip(strip_for_node(
780                                    idx,
781                                    &item.strip_lengths,
782                                )));
783                            }
784                        } else {
785                            hit.sub_object = None;
786                        }
787                        consider(toi, hit);
788                    }
789                }
790                for item in &self.pick_ribbon_items {
791                    if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
792                        continue;
793                    }
794                    let ref_pos = glam::Vec3::from(item.positions[0]);
795                    let radius_px = world_r_to_px(ref_pos, item.width * 0.5).max(8.0);
796                    if let Some(mut hit) = pick_gaussian_splat_cpu(
797                        click_pos,
798                        item.settings.pick_id.0,
799                        &item.positions,
800                        glam::Mat4::IDENTITY,
801                        view_proj,
802                        viewport_size,
803                        radius_px,
804                    ) {
805                        let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
806                        if wants_poly_node {
807                            // sub_object is already SubObjectRef::Point(node_index)
808                        } else if wants_strip {
809                            if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
810                                hit.sub_object = Some(SubObjectRef::Strip(strip_for_node(
811                                    idx,
812                                    &item.strip_lengths,
813                                )));
814                            }
815                        } else {
816                            hit.sub_object = None;
817                        }
818                        consider(toi, hit);
819                    }
820                }
821            }
822
823            // SEGMENT / STRIP / OBJECT pass using full geometric tests.
824            if wants_segment || wants_strip || wants_object {
825                // Streamtube: project each cylinder axis segment to screen and find the
826                // closest point along the full segment (not just the midpoint).
827                for item in &self.pick_streamtube_items {
828                    if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
829                        continue;
830                    }
831                    let ref_pos = glam::Vec3::from(item.positions[0]);
832                    let threshold_px = world_r_to_px(ref_pos, item.radius.max(0.01));
833                    let Some((seg_idx, world_pos)) = pick_closest_polyline_segment(
834                        click_pos,
835                        viewport_size,
836                        view_proj,
837                        &item.positions,
838                        &item.strip_lengths,
839                        threshold_px,
840                    ) else {
841                        continue;
842                    };
843                    let toi = (world_pos - ray_origin).dot(ray_dir).max(0.0);
844                    let sub_object = if wants_segment {
845                        Some(SubObjectRef::Segment(seg_idx))
846                    } else if wants_strip {
847                        Some(SubObjectRef::Strip(strip_for_segment(
848                            seg_idx,
849                            &item.strip_lengths,
850                        )))
851                    } else {
852                        None
853                    };
854                    #[allow(deprecated)]
855                    consider(
856                        toi,
857                        PickHit {
858                            id: item.settings.pick_id.0,
859                            sub_object,
860                            world_pos,
861                            normal: glam::Vec3::Z,
862                            triangle_index: u32::MAX,
863                            point_index: None,
864                            scalar_value: None,
865                        },
866                    );
867                }
868
869                // Tube: same as streamtube; uses the conservative max of uniform and
870                // per-point radii for the screen-space threshold.
871                for item in &self.pick_tube_items {
872                    if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
873                        continue;
874                    }
875                    let ref_pos = glam::Vec3::from(item.positions[0]);
876                    let max_r = item
877                        .radius_attribute
878                        .as_ref()
879                        .and_then(|ra| ra.iter().copied().reduce(f32::max))
880                        .unwrap_or(0.0)
881                        .max(item.radius)
882                        .max(0.01);
883                    let threshold_px = world_r_to_px(ref_pos, max_r);
884                    let Some((seg_idx, world_pos)) = pick_closest_polyline_segment(
885                        click_pos,
886                        viewport_size,
887                        view_proj,
888                        &item.positions,
889                        &item.strip_lengths,
890                        threshold_px,
891                    ) else {
892                        continue;
893                    };
894                    let toi = (world_pos - ray_origin).dot(ray_dir).max(0.0);
895                    let sub_object = if wants_segment {
896                        Some(SubObjectRef::Segment(seg_idx))
897                    } else if wants_strip {
898                        Some(SubObjectRef::Strip(strip_for_segment(
899                            seg_idx,
900                            &item.strip_lengths,
901                        )))
902                    } else {
903                        None
904                    };
905                    #[allow(deprecated)]
906                    consider(
907                        toi,
908                        PickHit {
909                            id: item.settings.pick_id.0,
910                            sub_object,
911                            world_pos,
912                            normal: glam::Vec3::Z,
913                            triangle_index: u32::MAX,
914                            point_index: None,
915                            scalar_value: None,
916                        },
917                    );
918                }
919
920                // Ribbon: reconstruct the swept quad per segment (parallel-transport
921                // lateral frame) and test the ray against both triangles of each quad.
922                for item in &self.pick_ribbon_items {
923                    if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
924                        continue;
925                    }
926                    let frames = ribbon_lateral_frames(
927                        &item.positions,
928                        &item.strip_lengths,
929                        item.width,
930                        item.width_attribute.as_deref(),
931                        item.twist_attribute.as_deref(),
932                    );
933
934                    let single;
935                    let strips: &[u32] = if item.strip_lengths.is_empty() {
936                        single = [item.positions.len() as u32];
937                        &single
938                    } else {
939                        &item.strip_lengths
940                    };
941
942                    let mut best_t = f32::MAX;
943                    let mut best_seg: Option<(u32, glam::Vec3)> = None;
944                    let mut node_off = 0usize;
945                    let mut seg_off = 0u32;
946
947                    for &slen in strips {
948                        let slen = slen as usize;
949                        for k in 0..slen.saturating_sub(1) {
950                            let ia = node_off + k;
951                            let ib = node_off + k + 1;
952                            let pa = glam::Vec3::from(item.positions[ia]);
953                            let pb = glam::Vec3::from(item.positions[ib]);
954                            let (ua, wa) = frames[ia];
955                            let (ub, wb) = frames[ib];
956                            // Quad corners: c0/c1 at segment start, c2/c3 at end.
957                            let c0 = pa + ua * wa; // left  at a
958                            let c1 = pa - ua * wa; // right at a
959                            let c2 = pb + ub * wb; // left  at b
960                            let c3 = pb - ub * wb; // right at b
961                            // Test 2 triangles, both front and back faces.
962                            let t = ray_triangle(ray_origin, ray_dir, c0, c1, c2)
963                                .or_else(|| ray_triangle(ray_origin, ray_dir, c1, c3, c2))
964                                .or_else(|| ray_triangle(ray_origin, ray_dir, c2, c1, c0))
965                                .or_else(|| ray_triangle(ray_origin, ray_dir, c2, c3, c1));
966                            if let Some(t) = t {
967                                if t < best_t {
968                                    best_t = t;
969                                    best_seg = Some((seg_off + k as u32, ray_origin + ray_dir * t));
970                                }
971                            }
972                        }
973                        seg_off += slen.saturating_sub(1) as u32;
974                        node_off += slen;
975                    }
976
977                    if let Some((seg_idx, world_pos)) = best_seg {
978                        let sub_object = if wants_segment {
979                            Some(SubObjectRef::Segment(seg_idx))
980                        } else if wants_strip {
981                            Some(SubObjectRef::Strip(strip_for_segment(
982                                seg_idx,
983                                &item.strip_lengths,
984                            )))
985                        } else {
986                            None
987                        };
988                        #[allow(deprecated)]
989                        consider(
990                            best_t,
991                            PickHit {
992                                id: item.settings.pick_id.0,
993                                sub_object,
994                                world_pos,
995                                normal: glam::Vec3::Z,
996                                triangle_index: u32::MAX,
997                                point_index: None,
998                                scalar_value: None,
999                            },
1000                        );
1001                    }
1002                }
1003            }
1004        }
1005
1006        // 10. Image slice / volume surface slice / screen image object picks (OBJECT only).
1007        if wants_object {
1008            // Image slice: axis-aligned quad ray intersection.
1009            for item in &self.pick_image_slice_items {
1010                if item.settings.pick_id == PickId::NONE {
1011                    continue;
1012                }
1013                let [bmin, bmax] = [item.bbox_min, item.bbox_max];
1014                let t = item.offset;
1015                // Plane normal and position along the axis.
1016                let (axis_idx, plane_pos) = match item.axis {
1017                    SliceAxis::X => (0usize, bmin[0] + t * (bmax[0] - bmin[0])),
1018                    SliceAxis::Y => (1usize, bmin[1] + t * (bmax[1] - bmin[1])),
1019                    SliceAxis::Z => (2usize, bmin[2] + t * (bmax[2] - bmin[2])),
1020                };
1021                let plane_n = {
1022                    let mut n = glam::Vec3::ZERO;
1023                    n[axis_idx] = 1.0;
1024                    n
1025                };
1026                let denom = plane_n.dot(ray_dir);
1027                if denom.abs() < 1e-6 {
1028                    continue;
1029                }
1030                let toi = (plane_pos - ray_origin[axis_idx]) / denom;
1031                if toi <= 0.0 {
1032                    continue;
1033                }
1034                let hit_pos = ray_origin + ray_dir * toi;
1035                // Check that the hit is within the slice quad's other two dimensions.
1036                let in_bounds = (0..3)
1037                    .filter(|&i| i != axis_idx)
1038                    .all(|i| hit_pos[i] >= bmin[i] - 1e-4 && hit_pos[i] <= bmax[i] + 1e-4);
1039                if in_bounds {
1040                    #[allow(deprecated)]
1041                    consider(
1042                        toi,
1043                        PickHit {
1044                            id: item.settings.pick_id.0,
1045                            sub_object: None,
1046                            world_pos: hit_pos,
1047                            normal: plane_n,
1048                            triangle_index: u32::MAX,
1049                            point_index: None,
1050                            scalar_value: None,
1051                        },
1052                    );
1053                }
1054            }
1055
1056            // Volume surface slice: ray/mesh intersection via mesh_store CPU data.
1057            for item in &self.pick_volume_surface_slice_items {
1058                if item.settings.pick_id == PickId::NONE {
1059                    continue;
1060                }
1061                let Some(mesh) = self.resources.mesh_store.get(item.mesh_id) else {
1062                    continue;
1063                };
1064                let (Some(positions), Some(indices)) = (&mesh.cpu_positions, &mesh.cpu_indices)
1065                else {
1066                    continue;
1067                };
1068                let model = glam::Mat4::from_cols_array_2d(&item.model);
1069                let verts: Vec<parry3d::math::Vector> = positions
1070                    .iter()
1071                    .map(|p| {
1072                        let wp = model.transform_point3(glam::Vec3::from(*p));
1073                        parry3d::math::Vector::new(wp.x, wp.y, wp.z)
1074                    })
1075                    .collect();
1076                let tri_indices: Vec<[u32; 3]> = indices
1077                    .chunks(3)
1078                    .filter(|c| c.len() == 3)
1079                    .map(|c| [c[0], c[1], c[2]])
1080                    .collect();
1081                if tri_indices.is_empty() {
1082                    continue;
1083                }
1084                let ray = parry3d::query::Ray::new(
1085                    parry3d::math::Vector::new(ray_origin.x, ray_origin.y, ray_origin.z),
1086                    parry3d::math::Vector::new(ray_dir.x, ray_dir.y, ray_dir.z),
1087                );
1088                if let Ok(trimesh) = parry3d::shape::TriMesh::new(verts, tri_indices) {
1089                    use parry3d::query::RayCast;
1090                    if let Some(hit) = trimesh.cast_ray_and_get_normal(
1091                        &parry3d::math::Pose::identity(),
1092                        &ray,
1093                        f32::MAX,
1094                        true,
1095                    ) {
1096                        let world_pos = ray_origin + ray_dir * hit.time_of_impact;
1097                        let n = hit.normal;
1098                        #[allow(deprecated)]
1099                        consider(
1100                            hit.time_of_impact,
1101                            PickHit {
1102                                id: item.settings.pick_id.0,
1103                                sub_object: None,
1104                                world_pos,
1105                                normal: glam::Vec3::new(n.x, n.y, n.z),
1106                                triangle_index: u32::MAX,
1107                                point_index: None,
1108                                scalar_value: None,
1109                            },
1110                        );
1111                    }
1112                }
1113            }
1114
1115            // Screen image: screen-space rect test. toi=0 so these win over any 3D hit.
1116            for item in &self.pick_screen_image_items {
1117                if item.settings.pick_id == PickId::NONE || item.width == 0 || item.height == 0 {
1118                    continue;
1119                }
1120                let img_w = item.width as f32 * item.scale;
1121                let img_h = item.height as f32 * item.scale;
1122                let (sx, sy) = match item.anchor {
1123                    ImageAnchor::TopLeft => (0.0, 0.0),
1124                    ImageAnchor::TopRight => (viewport_size.x - img_w, 0.0),
1125                    ImageAnchor::BottomLeft => (0.0, viewport_size.y - img_h),
1126                    ImageAnchor::BottomRight => (viewport_size.x - img_w, viewport_size.y - img_h),
1127                    ImageAnchor::Center => (
1128                        (viewport_size.x - img_w) * 0.5,
1129                        (viewport_size.y - img_h) * 0.5,
1130                    ),
1131                };
1132                if click_pos.x >= sx
1133                    && click_pos.x <= sx + img_w
1134                    && click_pos.y >= sy
1135                    && click_pos.y <= sy + img_h
1136                {
1137                    // No meaningful 3D position; place the hit at the near-plane.
1138                    let world_pos = ray_origin + ray_dir * 0.001;
1139                    #[allow(deprecated)]
1140                    consider(
1141                        0.0,
1142                        PickHit {
1143                            id: item.settings.pick_id.0,
1144                            sub_object: None,
1145                            world_pos,
1146                            normal: -ray_dir,
1147                            triangle_index: u32::MAX,
1148                            point_index: None,
1149                            scalar_value: None,
1150                        },
1151                    );
1152                }
1153            }
1154        }
1155
1156        // 11. GPU implicit surface picks (OBJECT only -- no sub-element model).
1157        if wants_object {
1158            for item in &self.pick_implicit_items {
1159                if let Some((toi, world_pos)) = pick_implicit_sdf(ray_origin, ray_dir, item) {
1160                    #[allow(deprecated)]
1161                    consider(
1162                        toi,
1163                        PickHit {
1164                            id: item.id,
1165                            sub_object: None,
1166                            world_pos,
1167                            normal: glam::Vec3::Z,
1168                            triangle_index: u32::MAX,
1169                            point_index: None,
1170                            scalar_value: None,
1171                        },
1172                    );
1173                }
1174            }
1175        }
1176
1177        // 12. GPU marching cubes surface picks (OBJECT only).
1178        if wants_object {
1179            for item in &self.pick_mc_items {
1180                if let Some((toi, world_pos)) = pick_mc_volume(ray_origin, ray_dir, item) {
1181                    #[allow(deprecated)]
1182                    consider(
1183                        toi,
1184                        PickHit {
1185                            id: item.id,
1186                            sub_object: None,
1187                            world_pos,
1188                            normal: glam::Vec3::Z,
1189                            triangle_index: u32::MAX,
1190                            point_index: None,
1191                            scalar_value: None,
1192                        },
1193                    );
1194                }
1195            }
1196        }
1197
1198        // 13. Decal picks (OBJECT only): ray versus the decal projection box.
1199        // A decal is the unit box [-0.5, 0.5]^3 mapped to world by `transform`.
1200        // The box front face typically hugs the receiver surface, so a decal
1201        // that straddles a surface wins over that surface by `toi`, letting a
1202        // click select the decal itself. A decal whose box floats in empty
1203        // space is still pickable wherever the ray passes through the volume.
1204        if wants_object {
1205            for item in &self.pick_decal_items {
1206                if item.settings.hidden || item.settings.pick_id == PickId::NONE {
1207                    continue;
1208                }
1209                let model = glam::Mat4::from_cols_array_2d(&item.transform);
1210                if model.determinant().abs() < 1e-12 {
1211                    continue;
1212                }
1213                let inv = model.inverse();
1214                let local_origin = inv.transform_point3(ray_origin);
1215                let local_dir = inv.transform_vector3(ray_dir);
1216                if let Some(toi) = ray_unit_box_toi(local_origin, local_dir) {
1217                    let world_pos = ray_origin + ray_dir * toi;
1218                    #[allow(deprecated)]
1219                    consider(
1220                        toi,
1221                        PickHit {
1222                            id: item.settings.pick_id.0,
1223                            sub_object: None,
1224                            world_pos,
1225                            normal: -ray_dir.normalize_or_zero(),
1226                            triangle_index: u32::MAX,
1227                            point_index: None,
1228                            scalar_value: None,
1229                        },
1230                    );
1231                }
1232            }
1233        }
1234
1235        // Consult registered item-type plugins after the built-in pickers.
1236        // Each plugin returns its own closest hit; the router compares
1237        // by world-space ray t against the running best.
1238        if !self.item_type_plugins.is_empty() {
1239            let plugin_ray = crate::plugin_api::PickRay {
1240                origin: ray_origin,
1241                direction: ray_dir,
1242            };
1243            for plugin in self.item_type_plugins.values() {
1244                if let Some((t, hit)) = plugin.pick(&plugin_ray) {
1245                    consider(t, hit);
1246                }
1247            }
1248        }
1249
1250        best.map(|(_, hit)| hit)
1251    }
1252}