Skip to main content

viewport_lib/renderer/
picking.rs

1use super::*;
2
3// ---------------------------------------------------------------------------
4// Strip index helpers (shared by polyline, tube, ribbon picking)
5// ---------------------------------------------------------------------------
6
7/// Map a global node index to its strip index by walking `strip_lengths`.
8fn strip_for_node(node_idx: u32, strip_lengths: &[u32]) -> u32 {
9    let mut offset = 0u32;
10    for (i, &len) in strip_lengths.iter().enumerate() {
11        offset += len;
12        if node_idx < offset {
13            return i as u32;
14        }
15    }
16    strip_lengths.len().saturating_sub(1) as u32
17}
18
19/// Find the closest polyline segment to `click_pos` within `threshold_px` pixels.
20///
21/// Returns `(global_seg_idx, world_hit_pos)` on hit, `None` otherwise. Positions
22/// are treated as world-space (polylines are always submitted without a model
23/// transform). The hit position is the closest point on the segment in 3D,
24/// interpolated at the same screen-space parameter `t` as the closest screen point.
25fn pick_closest_polyline_segment(
26    click_pos: glam::Vec2,
27    viewport_size: glam::Vec2,
28    view_proj: glam::Mat4,
29    positions: &[[f32; 3]],
30    strip_lengths: &[u32],
31    threshold_px: f32,
32) -> Option<(u32, glam::Vec3)> {
33    let project = |p: [f32; 3]| -> Option<glam::Vec2> {
34        let clip = view_proj * glam::Vec4::new(p[0], p[1], p[2], 1.0);
35        if clip.w <= 0.0 {
36            return None;
37        }
38        Some(glam::Vec2::new(
39            (clip.x / clip.w + 1.0) * 0.5 * viewport_size.x,
40            (1.0 - clip.y / clip.w) * 0.5 * viewport_size.y,
41        ))
42    };
43
44    let mut best_dist = threshold_px;
45    let mut best: Option<(u32, glam::Vec3)> = None;
46
47    macro_rules! try_seg {
48        ($ai:expr, $bi:expr, $seg:expr) => {{
49            if let (Some(sa), Some(sb)) = (project(positions[$ai]), project(positions[$bi])) {
50                let ab = sb - sa;
51                let len_sq = ab.length_squared();
52                let t = if len_sq < 1e-6 {
53                    0.0f32
54                } else {
55                    ((click_pos - sa).dot(ab) / len_sq).clamp(0.0, 1.0)
56                };
57                let dist = (click_pos - (sa + ab * t)).length();
58                if dist < best_dist {
59                    best_dist = dist;
60                    let wa = glam::Vec3::from(positions[$ai]);
61                    let wb = glam::Vec3::from(positions[$bi]);
62                    best = Some(($seg as u32, wa.lerp(wb, t)));
63                }
64            }
65        }};
66    }
67
68    if strip_lengths.is_empty() {
69        for j in 0..positions.len().saturating_sub(1) {
70            try_seg!(j, j + 1, j);
71        }
72    } else {
73        let mut node_off = 0usize;
74        let mut seg_off = 0u32;
75        for &slen in strip_lengths {
76            let slen = slen as usize;
77            for j in 0..slen.saturating_sub(1) {
78                try_seg!(node_off + j, node_off + j + 1, seg_off + j as u32);
79            }
80            seg_off += slen.saturating_sub(1) as u32;
81            node_off += slen;
82        }
83    }
84
85    best
86}
87
88/// Returns `true` if the 2D segment [a, b] touches or crosses the axis-aligned rect.
89fn segment_in_rect(
90    a: glam::Vec2,
91    b: glam::Vec2,
92    rect_min: glam::Vec2,
93    rect_max: glam::Vec2,
94) -> bool {
95    // Quick AABB reject.
96    if a.x.min(b.x) > rect_max.x
97        || a.x.max(b.x) < rect_min.x
98        || a.y.min(b.y) > rect_max.y
99        || a.y.max(b.y) < rect_min.y
100    {
101        return false;
102    }
103    // Either endpoint inside?
104    let in_r = |p: glam::Vec2| {
105        p.x >= rect_min.x && p.x <= rect_max.x && p.y >= rect_min.y && p.y <= rect_max.y
106    };
107    if in_r(a) || in_r(b) {
108        return true;
109    }
110    // Segment crosses one of the 4 edges (parametric intersection test).
111    let crosses = |p0: glam::Vec2, p1: glam::Vec2, q0: glam::Vec2, q1: glam::Vec2| -> bool {
112        let d = p1 - p0;
113        let e = q1 - q0;
114        let denom = d.x * e.y - d.y * e.x;
115        if denom.abs() < 1e-10 {
116            return false;
117        }
118        let diff = q0 - p0;
119        let t = (diff.x * e.y - diff.y * e.x) / denom;
120        let u = (diff.x * d.y - diff.y * d.x) / denom;
121        t >= 0.0 && t <= 1.0 && u >= 0.0 && u <= 1.0
122    };
123    let tl = rect_min;
124    let tr = glam::Vec2::new(rect_max.x, rect_min.y);
125    let bl = glam::Vec2::new(rect_min.x, rect_max.y);
126    let br = rect_max;
127    crosses(a, b, tl, tr) || crosses(a, b, tr, br) || crosses(a, b, br, bl) || crosses(a, b, bl, tl)
128}
129
130/// Map a global segment index to its strip index by walking `strip_lengths`.
131fn strip_for_segment(seg_idx: u32, strip_lengths: &[u32]) -> u32 {
132    let mut offset = 0u32;
133    for (i, &len) in strip_lengths.iter().enumerate() {
134        let segs = len.saturating_sub(1);
135        offset += segs;
136        if seg_idx < offset {
137            return i as u32;
138        }
139    }
140    strip_lengths.len().saturating_sub(1) as u32
141}
142
143/// Möller-Trumbore ray-triangle intersection.
144///
145/// Returns the ray parameter `t > 0` on hit, or `None` on miss or backface cull.
146/// Call twice with reversed winding to test both faces.
147#[inline]
148fn ray_triangle(
149    ray_orig: glam::Vec3,
150    ray_dir: glam::Vec3,
151    v0: glam::Vec3,
152    v1: glam::Vec3,
153    v2: glam::Vec3,
154) -> Option<f32> {
155    let e1 = v1 - v0;
156    let e2 = v2 - v0;
157    let h = ray_dir.cross(e2);
158    let a = e1.dot(h);
159    if a.abs() < 1e-10 {
160        return None;
161    }
162    let f = 1.0 / a;
163    let s = ray_orig - v0;
164    let u = f * s.dot(h);
165    if u < 0.0 || u > 1.0 {
166        return None;
167    }
168    let q = s.cross(e1);
169    let v = f * ray_dir.dot(q);
170    if v < 0.0 || u + v > 1.0 {
171        return None;
172    }
173    let t = f * e2.dot(q);
174    if t > 0.0 { Some(t) } else { None }
175}
176
177/// Reconstruct per-vertex (lateral direction, half-width) for a ribbon item.
178///
179/// Replicates the parallel-transport frame built by `upload_ribbon()` in
180/// `prepare.rs` so click and rect picking can test the actual swept quad
181/// rather than a midpoint proxy.
182fn ribbon_lateral_frames(
183    positions: &[[f32; 3]],
184    strip_lengths: &[u32],
185    width: f32,
186    width_attribute: Option<&[f32]>,
187    twist_attribute: Option<&[[f32; 3]]>,
188) -> Vec<(glam::Vec3, f32)> {
189    let n = positions.len();
190    // Initialise with a sentinel so any unvisited vertex has zero width.
191    let mut frames: Vec<(glam::Vec3, f32)> = vec![(glam::Vec3::X, 0.0); n];
192
193    let single;
194    let strips: &[u32] = if strip_lengths.is_empty() {
195        single = [positions.len() as u32];
196        &single
197    } else {
198        strip_lengths
199    };
200
201    let mut node_off = 0usize;
202    for &slen in strips {
203        let slen = slen as usize;
204        if slen < 2 {
205            node_off += slen;
206            continue;
207        }
208
209        let pts: Vec<glam::Vec3> = positions[node_off..node_off + slen]
210            .iter()
211            .map(|&p| glam::Vec3::from(p))
212            .collect();
213
214        let t0 = (pts[1] - pts[0]).normalize_or_zero();
215        if t0.length_squared() < 1e-10 {
216            node_off += slen;
217            continue;
218        }
219        let ref_v = if t0.x.abs() < 0.9 {
220            glam::Vec3::X
221        } else {
222            glam::Vec3::Y
223        };
224        let mut u = t0.cross(ref_v).normalize();
225
226        for k in 0..slen {
227            let tangent = if k + 1 < slen {
228                (pts[k + 1] - pts[k]).normalize_or_zero()
229            } else {
230                (pts[k] - pts[k - 1]).normalize_or_zero()
231            };
232
233            // Parallel transport: rotate u to stay perpendicular to the new tangent.
234            if k > 0 {
235                let t_prev = (pts[k] - pts[k - 1]).normalize_or_zero();
236                let axis = t_prev.cross(tangent);
237                let sin_a = axis.length().min(1.0);
238                if sin_a > 1e-6 {
239                    let cos_a = t_prev.dot(tangent).clamp(-1.0, 1.0);
240                    let ax = axis / sin_a;
241                    u = u * cos_a + ax.cross(u) * sin_a + ax * ax.dot(u) * (1.0 - cos_a);
242                    u = u.normalize_or_zero();
243                }
244            }
245
246            // Apply per-point twist if supplied.
247            let mut lateral = u;
248            if let Some(twist) = twist_attribute {
249                if let Some(&tv) = twist.get(node_off + k) {
250                    let tv = glam::Vec3::from(tv);
251                    let proj = tv - tangent * tangent.dot(tv);
252                    if proj.length_squared() > 1e-10 {
253                        lateral = proj.normalize();
254                    }
255                }
256            }
257
258            let half_w = width_attribute
259                .and_then(|wa| wa.get(node_off + k).copied())
260                .unwrap_or(width)
261                * 0.5;
262
263            frames[node_off + k] = (lateral, half_w);
264        }
265
266        node_off += slen;
267    }
268
269    frames
270}
271
272// ---------------------------------------------------------------------------
273// CPU SDF evaluation for GPU implicit surfaces (mirrors implicit.wgsl)
274// ---------------------------------------------------------------------------
275
276/// Evaluate one implicit primitive's signed distance from `p`.
277fn eval_implicit_primitive(p: glam::Vec3, prim: &crate::resources::ImplicitPrimitive) -> f32 {
278    match prim.kind {
279        1 => {
280            // Sphere: center=params[0..3], radius=params[3]
281            let center = glam::Vec3::new(prim.params[0], prim.params[1], prim.params[2]);
282            (p - center).length() - prim.params[3]
283        }
284        2 => {
285            // Box: center=params[0..3], half-extents=params[4..7]
286            let center = glam::Vec3::new(prim.params[0], prim.params[1], prim.params[2]);
287            let half = glam::Vec3::new(prim.params[4], prim.params[5], prim.params[6]);
288            let q = (p - center).abs() - half;
289            q.max(glam::Vec3::ZERO).length() + q.x.max(q.y).max(q.z).min(0.0)
290        }
291        3 => {
292            // Plane: normal=params[0..3], offset=params[3]
293            let n =
294                glam::Vec3::new(prim.params[0], prim.params[1], prim.params[2]).normalize_or_zero();
295            p.dot(n) + prim.params[3]
296        }
297        4 => {
298            // Capsule: a=params[0..3], radius=params[3], b=params[4..7]
299            let a = glam::Vec3::new(prim.params[0], prim.params[1], prim.params[2]);
300            let r = prim.params[3];
301            let b = glam::Vec3::new(prim.params[4], prim.params[5], prim.params[6]);
302            let pa = p - a;
303            let ba = b - a;
304            let h = (pa.dot(ba) / ba.dot(ba).max(1e-10)).clamp(0.0, 1.0);
305            (pa - ba * h).length() - r
306        }
307        _ => f32::MAX,
308    }
309}
310
311/// Polynomial smooth-min (Inigo Quilez).
312#[inline]
313fn smin_implicit(a: f32, b: f32, k: f32) -> f32 {
314    let h = (0.5 + 0.5 * (b - a) / k).clamp(0.0, 1.0);
315    a * h + b * (1.0 - h) - k * h * (1.0 - h)
316}
317
318/// Evaluate the combined SDF for all primitives in one GPU implicit item.
319fn eval_implicit_sdf(p: glam::Vec3, item: &GpuImplicitPickItem) -> f32 {
320    use crate::resources::ImplicitBlendMode;
321    let mut d = item.max_distance;
322    for (i, prim) in item.primitives.iter().enumerate() {
323        let pd = eval_implicit_primitive(p, prim);
324        match item.blend_mode {
325            ImplicitBlendMode::Union => {
326                d = d.min(pd);
327            }
328            ImplicitBlendMode::SmoothUnion => {
329                let k = if prim.blend > 0.0 { prim.blend } else { 1e-5 };
330                d = smin_implicit(d, pd, k);
331            }
332            ImplicitBlendMode::Intersection => {
333                if i == 0 {
334                    d = pd;
335                } else {
336                    d = d.max(pd);
337                }
338            }
339        }
340    }
341    d
342}
343
344/// CPU ray-march against the implicit SDF. Returns `(toi, world_pos)` on hit.
345fn pick_implicit_sdf(
346    ray_origin: glam::Vec3,
347    ray_dir: glam::Vec3,
348    item: &GpuImplicitPickItem,
349) -> Option<(f32, glam::Vec3)> {
350    let max_steps = item.max_steps.min(512) as usize;
351    let scale = item.step_scale.clamp(0.01, 1.0);
352    let hit_thr = item.hit_threshold;
353    let max_dist = item.max_distance;
354    let min_step = hit_thr * 0.5;
355
356    let mut t = 0.0f32;
357    for _ in 0..max_steps {
358        if t > max_dist {
359            break;
360        }
361        let p = ray_origin + ray_dir * t;
362        let d = eval_implicit_sdf(p, item);
363        if d < hit_thr {
364            return Some((t, p));
365        }
366        t += d.abs().max(min_step) * scale;
367    }
368    None
369}
370
371// ---------------------------------------------------------------------------
372// CPU volume ray-march for GPU marching cubes isosurface picking
373// ---------------------------------------------------------------------------
374
375/// Slab test: returns (t_enter, t_exit) for a ray vs axis-aligned box, or None.
376fn ray_aabb_slab(
377    ray_orig: glam::Vec3,
378    ray_dir: glam::Vec3,
379    bbox_min: glam::Vec3,
380    bbox_max: glam::Vec3,
381) -> Option<(f32, f32)> {
382    // Avoid division by zero for axis-aligned rays.
383    let inv = glam::Vec3::new(
384        if ray_dir.x.abs() > 1e-30 {
385            1.0 / ray_dir.x
386        } else {
387            f32::INFINITY * ray_dir.x.signum()
388        },
389        if ray_dir.y.abs() > 1e-30 {
390            1.0 / ray_dir.y
391        } else {
392            f32::INFINITY * ray_dir.y.signum()
393        },
394        if ray_dir.z.abs() > 1e-30 {
395            1.0 / ray_dir.z
396        } else {
397            f32::INFINITY * ray_dir.z.signum()
398        },
399    );
400    let t1 = (bbox_min - ray_orig) * inv;
401    let t2 = (bbox_max - ray_orig) * inv;
402    let tmin = t1.min(t2);
403    let tmax = t1.max(t2);
404    let t_enter = tmin.x.max(tmin.y).max(tmin.z);
405    let t_exit = tmax.x.min(tmax.y).min(tmax.z);
406    if t_enter <= t_exit && t_exit >= 0.0 {
407        Some((t_enter, t_exit))
408    } else {
409        None
410    }
411}
412
413/// Bisect to refine the isovalue crossing between t_lo and t_hi (8 iterations).
414fn bisect_mc_crossing(
415    ray_orig: glam::Vec3,
416    ray_dir: glam::Vec3,
417    vol: &crate::geometry::marching_cubes::VolumeData,
418    isovalue: f32,
419    mut t_lo: f32,
420    mut t_hi: f32,
421) -> f32 {
422    let s0 = crate::geometry::marching_cubes::trilinear_sample(
423        vol,
424        (ray_orig + ray_dir * t_lo).to_array(),
425    ) - isovalue;
426    let mut lo_sign = s0 < 0.0;
427    for _ in 0..8 {
428        let mid = (t_lo + t_hi) * 0.5;
429        let s = crate::geometry::marching_cubes::trilinear_sample(
430            vol,
431            (ray_orig + ray_dir * mid).to_array(),
432        ) - isovalue;
433        if (s < 0.0) == lo_sign {
434            t_lo = mid;
435        } else {
436            t_hi = mid;
437            lo_sign = !lo_sign;
438        }
439    }
440    (t_lo + t_hi) * 0.5
441}
442
443/// CPU ray-march against a MC isosurface. Returns `(toi, world_pos)` on hit.
444///
445/// Steps through the volume AABB at half-cell intervals and refines any
446/// isovalue crossing to 8 bisection steps.
447fn pick_mc_volume(
448    ray_orig: glam::Vec3,
449    ray_dir: glam::Vec3,
450    item: &GpuMcPickItem,
451) -> Option<(f32, glam::Vec3)> {
452    use crate::geometry::marching_cubes::trilinear_sample;
453
454    let vol = &item.volume_data;
455    let isovalue = item.isovalue;
456    let [nx, ny, nz] = vol.dims;
457    let origin = glam::Vec3::from(vol.origin);
458    let spacing = glam::Vec3::from(vol.spacing);
459    let extent = spacing * glam::Vec3::new(nx as f32, ny as f32, nz as f32);
460
461    let (t_enter, t_exit) = ray_aabb_slab(ray_orig, ray_dir, origin, origin + extent)?;
462    let t_start = t_enter.max(0.0);
463    if t_start >= t_exit {
464        return None;
465    }
466
467    // Step at half the smallest cell spacing so we don't skip thin features.
468    let step = spacing.min_element() * 0.5;
469    let mut t = t_start;
470    let mut prev = trilinear_sample(vol, (ray_orig + ray_dir * t).to_array()) - isovalue;
471
472    loop {
473        t += step;
474        if t > t_exit {
475            break;
476        }
477        let p = ray_orig + ray_dir * t;
478        let cur = trilinear_sample(vol, p.to_array()) - isovalue;
479        if prev * cur <= 0.0 {
480            // Sign change detected: bisect and return.
481            let t_hit = bisect_mc_crossing(ray_orig, ray_dir, vol, isovalue, t - step, t);
482            let world_pos = ray_orig + ray_dir * t_hit;
483            return Some((t_hit, world_pos));
484        }
485        prev = cur;
486    }
487    None
488}
489
490// ---------------------------------------------------------------------------
491// PickRectResult
492// ---------------------------------------------------------------------------
493
494/// Result of a [`ViewportRenderer::pick_rect`] call.
495#[derive(Clone, Debug, Default)]
496pub struct PickRectResult {
497    /// IDs of whole items that have geometry inside the pick rect.
498    ///
499    /// Populated when [`crate::interaction::pick_mask::PickMask::OBJECT`] is set.
500    pub objects: Vec<u64>,
501    /// Sub-elements inside the pick rect as `(item_id, sub_object)` pairs.
502    ///
503    /// Populated when any sub-element bit is set in the mask. All entries
504    /// belong to the same geometric dimension when the mask is
505    /// dimension-homogeneous (the common case).
506    pub elements: Vec<(u64, crate::interaction::sub_object::SubObjectRef)>,
507}
508
509impl PickRectResult {
510    /// Returns `true` when no objects or elements were found.
511    pub fn is_empty(&self) -> bool {
512        self.objects.is_empty() && self.elements.is_empty()
513    }
514}
515
516impl ViewportRenderer {
517    // -----------------------------------------------------------------------
518    // Unified CPU pick : renderer.pick()
519    // -----------------------------------------------------------------------
520
521    /// Pick the nearest item or sub-element under `click_pos`.
522    ///
523    /// Dispatches across all item types retained from the last `prepare()` call.
524    /// The `mask` controls which item types and sub-element levels participate.
525    ///
526    /// Returns `None` if nothing matching the mask is under the cursor.
527    ///
528    /// # Arguments
529    /// * `click_pos`     - cursor position in viewport pixels (top-left origin)
530    /// * `viewport_size` - viewport width x height in pixels
531    /// * `view_proj`     - combined view x projection matrix from the last frame
532    /// * `mask`          - which item types and sub-element levels to include
533    ///
534    /// # Example
535    /// ```rust,ignore
536    /// if let Some(hit) = renderer.pick(cursor, vp_size, view_proj, PickMask::FACE) {
537    ///     println!("hit face {:?} on object {}", hit.sub_object, hit.id);
538    /// }
539    /// ```
540    pub fn pick(
541        &self,
542        click_pos: glam::Vec2,
543        viewport_size: glam::Vec2,
544        view_proj: glam::Mat4,
545        mask: crate::interaction::pick_mask::PickMask,
546    ) -> Option<crate::interaction::picking::PickHit> {
547        use crate::interaction::pick_mask::PickMask;
548        use crate::interaction::picking::{
549            PickHit, pick_gaussian_splat_cpu, pick_point_cloud_cpu,
550            pick_transparent_volume_mesh_cpu, pick_volume_cpu, screen_to_ray,
551        };
552        use crate::interaction::sub_object::SubObjectRef;
553        use parry3d::math::{Pose, Vector};
554        use parry3d::query::{Ray, RayCast};
555
556        if viewport_size.x <= 0.0 || viewport_size.y <= 0.0 {
557            return None;
558        }
559
560        let view_proj_inv = view_proj.inverse();
561        let (ray_origin, ray_dir) = screen_to_ray(click_pos, viewport_size, view_proj_inv);
562
563        let wants_face = mask.intersects(PickMask::FACE);
564        let wants_vertex = mask.intersects(PickMask::VERTEX);
565        let wants_cell = mask.intersects(PickMask::CELL);
566        let wants_cloud = mask.intersects(PickMask::CLOUD_POINT);
567        let wants_splat = mask.intersects(PickMask::SPLAT);
568        let wants_object = mask.intersects(PickMask::OBJECT);
569        let wants_mesh_sub = wants_face || wants_vertex || mask.intersects(PickMask::EDGE);
570
571        // (toi, hit) -- nearest hit so far across all types.
572        let mut best: Option<(f32, PickHit)> = None;
573
574        let mut consider = |toi: f32, hit: PickHit| {
575            if best.as_ref().map_or(true, |(bt, _)| toi < *bt) {
576                best = Some((toi, hit));
577            }
578        };
579
580        // Build lookup for opaque volume mesh face_to_cell maps (used in section 1
581        // to convert surface Face hits to Cell hits).
582        let vm_cell_map: std::collections::HashMap<u64, &[u32]> = self
583            .pick_volume_mesh_items
584            .iter()
585            .filter(|item| item.settings.pick_id != PickId::NONE && !item.face_to_cell.is_empty())
586            .map(|item| (item.settings.pick_id.0, item.face_to_cell.as_slice()))
587            .collect();
588
589        // 1. Surface mesh picks (FACE, VERTEX, EDGE, CELL, or OBJECT fallback).
590        if wants_mesh_sub || wants_cell || wants_object {
591            let ray = Ray::new(
592                Vector::new(ray_origin.x, ray_origin.y, ray_origin.z),
593                Vector::new(ray_dir.x, ray_dir.y, ray_dir.z),
594            );
595            for item in &self.pick_scene_items {
596                if item.settings.hidden || item.settings.pick_id == PickId::NONE {
597                    continue;
598                }
599                let Some(mesh) = self.resources.mesh_store.get(item.mesh_id) else {
600                    continue;
601                };
602                let (Some(positions), Some(indices)) = (&mesh.cpu_positions, &mesh.cpu_indices)
603                else {
604                    continue;
605                };
606
607                let model = glam::Mat4::from_cols_array_2d(&item.model);
608
609                // Bake the full model matrix into vertex positions so that
610                // non-uniform scale is handled correctly.
611                let verts: Vec<Vector> = positions
612                    .iter()
613                    .map(|p| {
614                        let wp = model.transform_point3(glam::Vec3::from(*p));
615                        Vector::new(wp.x, wp.y, wp.z)
616                    })
617                    .collect();
618
619                let tri_indices: Vec<[u32; 3]> = indices
620                    .chunks(3)
621                    .filter(|c| c.len() == 3)
622                    .map(|c| [c[0], c[1], c[2]])
623                    .collect();
624
625                if tri_indices.is_empty() {
626                    continue;
627                }
628
629                match parry3d::shape::TriMesh::new(verts, tri_indices) {
630                    Ok(trimesh) => {
631                        // Vertices are already in world space: use identity pose.
632                        let identity = Pose::identity();
633                        let Some(intersection) =
634                            trimesh.cast_ray_and_get_normal(&identity, &ray, f32::MAX, true)
635                        else {
636                            continue;
637                        };
638                        let toi = intersection.time_of_impact;
639                        let world_pos = ray_origin + ray_dir * toi;
640                        let normal = intersection.normal;
641
642                        let feature_sub = SubObjectRef::from_feature_id(intersection.feature);
643
644                        let sub_object = if wants_face {
645                            feature_sub
646                        } else if wants_cell {
647                            // Convert surface Face hit to originating cell index.
648                            if let Some(f2c) = vm_cell_map.get(&item.settings.pick_id.0) {
649                                match feature_sub {
650                                    Some(SubObjectRef::Face(face_raw)) => {
651                                        let n_tri = indices.len() / 3;
652                                        let face = if (face_raw as usize) >= n_tri {
653                                            face_raw as usize - n_tri
654                                        } else {
655                                            face_raw as usize
656                                        };
657                                        f2c.get(face).map(|&ci| SubObjectRef::Cell(ci))
658                                    }
659                                    other => other,
660                                }
661                            } else if wants_vertex {
662                                // No cell map for this item; try vertex picking instead.
663                                // Fall through to the vertex branch below by
664                                // re-evaluating with the vertex logic inline.
665                                match feature_sub {
666                                    Some(SubObjectRef::Face(face_raw)) => {
667                                        let n_tri = indices.len() / 3;
668                                        let face = if (face_raw as usize) >= n_tri {
669                                            face_raw as usize - n_tri
670                                        } else {
671                                            face_raw as usize
672                                        };
673                                        if face * 3 + 2 < indices.len() {
674                                            let vis = [
675                                                indices[face * 3] as usize,
676                                                indices[face * 3 + 1] as usize,
677                                                indices[face * 3 + 2] as usize,
678                                            ];
679                                            let (best_vi, _) = vis
680                                                .iter()
681                                                .map(|&i| {
682                                                    let p = model.transform_point3(
683                                                        glam::Vec3::from(positions[i]),
684                                                    );
685                                                    (i, p.distance(world_pos))
686                                                })
687                                                .fold((vis[0], f32::MAX), |acc, (i, d)| {
688                                                    if d < acc.1 { (i, d) } else { acc }
689                                                });
690                                            Some(SubObjectRef::Vertex(best_vi as u32))
691                                        } else {
692                                            None
693                                        }
694                                    }
695                                    other => other,
696                                }
697                            } else {
698                                // No cell map and vertex not wanted; no sub-element.
699                                None
700                            }
701                        } else if wants_vertex {
702                            // Convert face hit to nearest triangle corner.
703                            match feature_sub {
704                                Some(SubObjectRef::Face(face_raw)) => {
705                                    let n_tri = indices.len() / 3;
706                                    let face = if (face_raw as usize) >= n_tri {
707                                        face_raw as usize - n_tri
708                                    } else {
709                                        face_raw as usize
710                                    };
711                                    if face * 3 + 2 < indices.len() {
712                                        let vis = [
713                                            indices[face * 3] as usize,
714                                            indices[face * 3 + 1] as usize,
715                                            indices[face * 3 + 2] as usize,
716                                        ];
717                                        let (best_vi, _) = vis
718                                            .iter()
719                                            .map(|&i| {
720                                                let p = model.transform_point3(glam::Vec3::from(
721                                                    positions[i],
722                                                ));
723                                                (i, p.distance(world_pos))
724                                            })
725                                            .fold((vis[0], f32::MAX), |acc, (i, d)| {
726                                                if d < acc.1 { (i, d) } else { acc }
727                                            });
728                                        Some(SubObjectRef::Vertex(best_vi as u32))
729                                    } else {
730                                        None
731                                    }
732                                }
733                                other => other,
734                            }
735                        } else {
736                            // Object-only: no sub-element.
737                            None
738                        };
739
740                        // Only emit the hit if we produced a meaningful sub-element
741                        // or the caller explicitly asked for object-level hits.
742                        // Without this guard, an EDGE-only mask runs the ray-trimesh
743                        // intersection (because wants_mesh_sub is true) but falls through
744                        // to sub_object=None, producing a spurious object-level hit.
745                        if sub_object.is_some() || wants_object {
746                            #[allow(deprecated)]
747                            let hit = PickHit {
748                                id: item.settings.pick_id.0,
749                                sub_object,
750                                world_pos,
751                                normal,
752                                triangle_index: u32::MAX,
753                                point_index: None,
754                                scalar_value: None,
755                            };
756                            consider(toi, hit);
757                        }
758                    }
759                    Err(e) => {
760                        tracing::warn!(
761                            pick_id = item.settings.pick_id.0,
762                            error = %e,
763                            "TriMesh build failed in renderer.pick()"
764                        );
765                    }
766                }
767            }
768        }
769
770        // 2. Opaque volume mesh cell picks are handled in section 1 above via
771        // vm_cell_map (face_to_cell conversion on surface Face hits).
772
773        // 2c. Scatter-volume object picks. Ray-vs-shape intersection only;
774        // there is no sub-object level for participating media
775        if wants_object {
776            for item in &self.pick_scatter_volume_items {
777                if item.settings.hidden || item.settings.pick_id == PickId::NONE {
778                    continue;
779                }
780                if let Some((t_enter, _)) = crate::scene::scatter_volume::ray_intersect(
781                    &item.volume.shape,
782                    ray_origin,
783                    ray_dir,
784                ) {
785                    let world_pos = ray_origin + ray_dir * t_enter;
786                    let normal = (world_pos
787                        - match item.volume.shape {
788                            crate::scene::scatter_volume::ScatterShape::Box(b) => {
789                                (b.min + b.max) * 0.5
790                            }
791                            crate::scene::scatter_volume::ScatterShape::Sphere {
792                                center, ..
793                            } => glam::Vec3::from(center),
794                        })
795                    .try_normalize()
796                    .unwrap_or(glam::Vec3::Z);
797                    consider(
798                        t_enter,
799                        PickHit::object_hit(item.settings.pick_id.0, world_pos, normal),
800                    );
801                }
802            }
803        }
804
805        // 2b. Interior-inclusive cell picks for volume meshes rendering
806        //     transparently. Items rendering as opaque are handled in section 1
807        //     above via vm_cell_map (face_to_cell on the boundary surface).
808        if wants_cell || wants_object {
809            for item in &self.pick_volume_mesh_items {
810                if item.settings.pick_id == PickId::NONE
811                    || item.transparency.is_none()
812                {
813                    continue;
814                }
815                let Some(data) = item.volume_mesh_data.as_deref() else {
816                    continue;
817                };
818                let model = glam::Mat4::from_cols_array_2d(&item.model);
819                if let Some(mut hit) = pick_transparent_volume_mesh_cpu(
820                    ray_origin,
821                    ray_dir,
822                    item.settings.pick_id.0,
823                    model,
824                    data,
825                ) {
826                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
827                    if !wants_cell {
828                        hit.sub_object = None;
829                    }
830                    consider(toi, hit);
831                }
832            }
833        }
834
835        // 3. Point cloud picks (CLOUD_POINT or OBJECT fallback).
836        if wants_cloud || wants_object {
837            for item in &self.pick_point_cloud_items {
838                if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
839                    continue;
840                }
841                let radius_px = item.point_size.max(4.0);
842                if let Some(mut hit) = pick_point_cloud_cpu(
843                    click_pos,
844                    item.settings.pick_id.0,
845                    item,
846                    view_proj,
847                    viewport_size,
848                    radius_px,
849                ) {
850                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
851                    if !wants_cloud {
852                        hit.sub_object = None;
853                    }
854                    consider(toi, hit);
855                }
856            }
857        }
858
859        // 4. Volume voxel picks (VOXEL or OBJECT fallback).
860        let wants_voxel = mask.intersects(PickMask::VOXEL);
861        if wants_voxel || wants_object {
862            for item in &self.pick_volume_items {
863                if item.settings.pick_id == PickId::NONE {
864                    continue;
865                }
866                let Some(vol_data) = item.volume_data.as_deref() else {
867                    continue;
868                };
869                if let Some(mut hit) =
870                    pick_volume_cpu(ray_origin, ray_dir, item.settings.pick_id.0, item, vol_data)
871                {
872                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
873                    if !wants_voxel {
874                        hit.sub_object = None;
875                    }
876                    consider(toi, hit);
877                }
878            }
879        }
880
881        // 5. Gaussian splat picks (SPLAT or OBJECT fallback).
882        if wants_splat || wants_object {
883            for item in &self.pick_splat_items {
884                if item.settings.pick_id == PickId::NONE {
885                    continue;
886                }
887                let Some(gpu_set) = self.resources.gaussian_splat_store.get(item.id.0) else {
888                    continue;
889                };
890                if gpu_set.cpu_positions.is_empty() {
891                    continue;
892                }
893                let model = glam::Mat4::from_cols_array_2d(&item.model);
894                // Derive pick radius from the mean per-splat scale so that a
895                // click anywhere inside the visible disc registers as a hit.
896                let mean_max_scale: f32 = if gpu_set.cpu_scales.is_empty() {
897                    0.05
898                } else {
899                    gpu_set
900                        .cpu_scales
901                        .iter()
902                        .map(|s| s[0].max(s[1]).max(s[2]))
903                        .sum::<f32>()
904                        / gpu_set.cpu_scales.len() as f32
905                };
906                let world_radius = mean_max_scale * 3.0;
907                let center_w = model.transform_point3(glam::Vec3::ZERO);
908                let p0_clip = view_proj * center_w.extend(1.0);
909                let p1_clip = view_proj * (center_w + glam::Vec3::X * world_radius).extend(1.0);
910                let radius_px = if p0_clip.w.abs() > 1e-6 && p1_clip.w.abs() > 1e-6 {
911                    let p0_ndc = glam::Vec2::new(p0_clip.x, p0_clip.y) / p0_clip.w;
912                    let p1_ndc = glam::Vec2::new(p1_clip.x, p1_clip.y) / p1_clip.w;
913                    ((p1_ndc - p0_ndc).length() * 0.5 * viewport_size.x.max(viewport_size.y))
914                        .max(4.0)
915                } else {
916                    world_radius * 100.0
917                };
918                if let Some(mut hit) = pick_gaussian_splat_cpu(
919                    click_pos,
920                    item.settings.pick_id.0,
921                    &gpu_set.cpu_positions,
922                    model,
923                    view_proj,
924                    viewport_size,
925                    radius_px,
926                ) {
927                    // pick_gaussian_splat_cpu returns SubObjectRef::Point; remap to Splat.
928                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
929                    if wants_splat {
930                        if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
931                            hit.sub_object = Some(SubObjectRef::Splat(idx));
932                        }
933                    } else {
934                        hit.sub_object = None;
935                    }
936                    consider(toi, hit);
937                }
938            }
939        }
940
941        // 6. Instance picks (INSTANCE or OBJECT fallback) for glyphs, tensor glyphs, sprites.
942        let wants_instance = mask.intersects(PickMask::INSTANCE);
943        if wants_instance || wants_object {
944            // Convert a world-space radius at a given world position to a pixel threshold.
945            // Using the actual instance centroid rather than the model origin gives a correct
946            // pixel size when instances are offset far from the model's local origin.
947            let instance_radius_px = |world_center: glam::Vec3, world_r: f32| -> f32 {
948                let p0 = view_proj * world_center.extend(1.0);
949                let p1 = view_proj * (world_center + glam::Vec3::X * world_r).extend(1.0);
950                if p0.w.abs() > 1e-6 && p1.w.abs() > 1e-6 {
951                    let n0 = glam::Vec2::new(p0.x, p0.y) / p0.w;
952                    let n1 = glam::Vec2::new(p1.x, p1.y) / p1.w;
953                    ((n1 - n0).length() * 0.5 * viewport_size.x.max(viewport_size.y)).max(4.0)
954                } else {
955                    (world_r * 100.0_f32).max(4.0)
956                }
957            };
958
959            // Glyphs
960            for item in &self.pick_glyph_items {
961                if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
962                    continue;
963                }
964                let model = glam::Mat4::from_cols_array_2d(&item.model);
965                let full_len = if item.scale_by_magnitude && !item.vectors.is_empty() {
966                    let mean_mag = item
967                        .vectors
968                        .iter()
969                        .map(|v| glam::Vec3::from(*v).length())
970                        .sum::<f32>()
971                        / item.vectors.len() as f32;
972                    (mean_mag * item.scale).max(0.01)
973                } else {
974                    item.scale.max(0.01)
975                };
976                // Test against the midpoint of each arrow (base + half-vector) with
977                // world_r = half-length. This prevents the hit circle from extending a full
978                // arrow-length behind the base when the arrow points away from the camera.
979                let has_vecs = item.vectors.len() == item.positions.len();
980                let midpoints: Vec<[f32; 3]> = item
981                    .positions
982                    .iter()
983                    .enumerate()
984                    .map(|(i, pos)| {
985                        if has_vecs {
986                            let p = glam::Vec3::from(*pos);
987                            let v = glam::Vec3::from(item.vectors[i]);
988                            let len = if item.scale_by_magnitude {
989                                v.length() * item.scale
990                            } else {
991                                item.scale
992                            };
993                            (p + v.normalize_or_zero() * len * 0.5).to_array()
994                        } else {
995                            *pos
996                        }
997                    })
998                    .collect();
999                let n = midpoints.len() as f32;
1000                let centroid = model.transform_point3(
1001                    midpoints
1002                        .iter()
1003                        .map(|p| glam::Vec3::from(*p))
1004                        .sum::<glam::Vec3>()
1005                        / n,
1006                );
1007                let radius_px = instance_radius_px(centroid, full_len * 0.5);
1008                if let Some(mut hit) = pick_gaussian_splat_cpu(
1009                    click_pos,
1010                    item.settings.pick_id.0,
1011                    &midpoints,
1012                    model,
1013                    view_proj,
1014                    viewport_size,
1015                    radius_px,
1016                ) {
1017                    // Report the base position, not the midpoint.
1018                    if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
1019                        if let Some(base) = item.positions.get(idx as usize) {
1020                            hit.world_pos = model.transform_point3(glam::Vec3::from(*base));
1021                        }
1022                        if wants_instance {
1023                            hit.sub_object = Some(SubObjectRef::Instance(idx));
1024                        } else {
1025                            hit.sub_object = None;
1026                        }
1027                    }
1028                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
1029                    consider(toi, hit);
1030                }
1031            }
1032
1033            // Tensor glyphs
1034            for item in &self.pick_tensor_glyph_items {
1035                if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
1036                    continue;
1037                }
1038                let model = glam::Mat4::from_cols_array_2d(&item.model);
1039                // Use the max eigenvalue across all instances so the largest ellipsoid
1040                // is fully covered. Use the centroid of instance positions for an accurate
1041                // pixel-size estimate (instances may be far from the model origin).
1042                let world_r = if !item.eigenvalues.is_empty() {
1043                    let max_ev = item
1044                        .eigenvalues
1045                        .iter()
1046                        .map(|ev| ev[0].abs().max(ev[1].abs()).max(ev[2].abs()))
1047                        .fold(0.0_f32, f32::max);
1048                    (max_ev * item.scale).max(0.01)
1049                } else {
1050                    item.scale.max(0.01)
1051                };
1052                let n = item.positions.len() as f32;
1053                let centroid = model.transform_point3(
1054                    item.positions
1055                        .iter()
1056                        .map(|p| glam::Vec3::from(*p))
1057                        .sum::<glam::Vec3>()
1058                        / n,
1059                );
1060                let radius_px = instance_radius_px(centroid, world_r);
1061                if let Some(mut hit) = pick_gaussian_splat_cpu(
1062                    click_pos,
1063                    item.settings.pick_id.0,
1064                    &item.positions,
1065                    model,
1066                    view_proj,
1067                    viewport_size,
1068                    radius_px,
1069                ) {
1070                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
1071                    if wants_instance {
1072                        if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
1073                            hit.sub_object = Some(SubObjectRef::Instance(idx));
1074                        }
1075                    } else {
1076                        hit.sub_object = None;
1077                    }
1078                    consider(toi, hit);
1079                }
1080            }
1081
1082            // Sprites
1083            for item in &self.pick_sprite_items {
1084                if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
1085                    continue;
1086                }
1087                let model = glam::Mat4::from_cols_array_2d(&item.model);
1088                let radius_px = match item.size_mode {
1089                    SpriteSizeMode::ScreenSpace => (item.default_size * 0.5).max(4.0),
1090                    SpriteSizeMode::WorldSpace => {
1091                        let n = item.positions.len() as f32;
1092                        let centroid = model.transform_point3(
1093                            item.positions
1094                                .iter()
1095                                .map(|p| glam::Vec3::from(*p))
1096                                .sum::<glam::Vec3>()
1097                                / n,
1098                        );
1099                        instance_radius_px(centroid, (item.default_size * 0.5).max(0.01))
1100                    }
1101                };
1102                if let Some(mut hit) = pick_gaussian_splat_cpu(
1103                    click_pos,
1104                    item.settings.pick_id.0,
1105                    &item.positions,
1106                    model,
1107                    view_proj,
1108                    viewport_size,
1109                    radius_px,
1110                ) {
1111                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
1112                    if wants_instance {
1113                        if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
1114                            hit.sub_object = Some(SubObjectRef::Instance(idx));
1115                        }
1116                    } else {
1117                        hit.sub_object = None;
1118                    }
1119                    consider(toi, hit);
1120                }
1121            }
1122        }
1123
1124        // 7. Polyline node picks (POLY_NODE, STRIP, or OBJECT fallback).
1125        let wants_poly_node = mask.intersects(PickMask::POLY_NODE);
1126        let wants_strip = mask.intersects(PickMask::STRIP);
1127        if wants_poly_node || wants_strip || wants_object {
1128            for item in &self.pick_polyline_items {
1129                if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
1130                    continue;
1131                }
1132                let radius_px = (item.line_width + 4.0).max(8.0);
1133                if let Some(mut hit) = pick_gaussian_splat_cpu(
1134                    click_pos,
1135                    item.settings.pick_id.0,
1136                    &item.positions,
1137                    glam::Mat4::IDENTITY,
1138                    view_proj,
1139                    viewport_size,
1140                    radius_px,
1141                ) {
1142                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
1143                    if wants_poly_node {
1144                        // sub_object is already SubObjectRef::Point(node_index)
1145                    } else if wants_strip {
1146                        if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
1147                            hit.sub_object = Some(SubObjectRef::Strip(strip_for_node(
1148                                idx,
1149                                &item.strip_lengths,
1150                            )));
1151                        }
1152                    } else {
1153                        hit.sub_object = None;
1154                    }
1155                    consider(toi, hit);
1156                }
1157            }
1158        }
1159
1160        // 8. Polyline segment picks (SEGMENT, STRIP, or OBJECT fallback).
1161        // Uses screen-space distance from the click to the full segment line so
1162        // clicking anywhere along a segment registers, not just near the midpoint.
1163        let wants_segment = mask.intersects(PickMask::SEGMENT);
1164        if wants_segment || wants_strip || wants_object {
1165            for item in &self.pick_polyline_items {
1166                if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
1167                    continue;
1168                }
1169                // Half the visual line width plus a few pixels of slack.
1170                let threshold_px = (item.line_width / 2.0 + 4.0).max(4.0);
1171                let Some((seg_idx, world_pos)) = pick_closest_polyline_segment(
1172                    click_pos,
1173                    viewport_size,
1174                    view_proj,
1175                    &item.positions,
1176                    &item.strip_lengths,
1177                    threshold_px,
1178                ) else {
1179                    continue;
1180                };
1181                let toi = (world_pos - ray_origin).dot(ray_dir).max(0.0);
1182                let sub_object = if wants_segment {
1183                    Some(SubObjectRef::Segment(seg_idx))
1184                } else if wants_strip {
1185                    Some(SubObjectRef::Strip(strip_for_segment(
1186                        seg_idx,
1187                        &item.strip_lengths,
1188                    )))
1189                } else {
1190                    None
1191                };
1192                #[allow(deprecated)]
1193                let hit = PickHit {
1194                    id: item.settings.pick_id.0,
1195                    sub_object,
1196                    world_pos,
1197                    normal: glam::Vec3::Z,
1198                    triangle_index: u32::MAX,
1199                    point_index: None,
1200                    scalar_value: None,
1201                };
1202                consider(toi, hit);
1203            }
1204        }
1205
1206        // 9. Streamtube / tube / ribbon picks (POLY_NODE, SEGMENT, STRIP, or OBJECT).
1207        // Streamtube / tube: screen-space closest-segment test against each cylinder
1208        //     axis (both endpoints projected), not just the midpoint.
1209        //   Ribbon: ray-triangle intersection against the reconstructed swept quad
1210        //     using the parallel-transport lateral frame.
1211        //   POLY_NODE: control points are point-like sub-elements (pick_gaussian_splat_cpu).
1212        if wants_poly_node || wants_segment || wants_strip || wants_object {
1213            // Convert a world-space radius at a reference point to a screen-pixel threshold.
1214            let world_r_to_px = |ref_world: glam::Vec3, world_r: f32| -> f32 {
1215                let p0 = view_proj * ref_world.extend(1.0);
1216                let p1 = view_proj * (ref_world + glam::Vec3::X * world_r).extend(1.0);
1217                if p0.w.abs() > 1e-6 && p1.w.abs() > 1e-6 {
1218                    let n0 = glam::Vec2::new(p0.x, p0.y) / p0.w;
1219                    let n1 = glam::Vec2::new(p1.x, p1.y) / p1.w;
1220                    ((n1 - n0).length() * 0.5 * viewport_size.x.max(viewport_size.y)).max(4.0)
1221                } else {
1222                    (world_r * 100.0_f32).max(4.0)
1223                }
1224            };
1225
1226            // POLY_NODE pass: nearest control point, promoted to Strip/Object as needed.
1227            if wants_poly_node || wants_strip || wants_object {
1228                for item in &self.pick_streamtube_items {
1229                    if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
1230                        continue;
1231                    }
1232                    let ref_pos = glam::Vec3::from(item.positions[0]);
1233                    let radius_px = world_r_to_px(ref_pos, item.radius.max(0.01)).max(8.0);
1234                    if let Some(mut hit) = pick_gaussian_splat_cpu(
1235                        click_pos,
1236                        item.settings.pick_id.0,
1237                        &item.positions,
1238                        glam::Mat4::IDENTITY,
1239                        view_proj,
1240                        viewport_size,
1241                        radius_px,
1242                    ) {
1243                        let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
1244                        if wants_poly_node {
1245                            // sub_object is already SubObjectRef::Point(node_index)
1246                        } else if wants_strip {
1247                            if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
1248                                hit.sub_object = Some(SubObjectRef::Strip(strip_for_node(
1249                                    idx,
1250                                    &item.strip_lengths,
1251                                )));
1252                            }
1253                        } else {
1254                            hit.sub_object = None;
1255                        }
1256                        consider(toi, hit);
1257                    }
1258                }
1259                for item in &self.pick_tube_items {
1260                    if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
1261                        continue;
1262                    }
1263                    let ref_pos = glam::Vec3::from(item.positions[0]);
1264                    let max_r = item
1265                        .radius_attribute
1266                        .as_ref()
1267                        .and_then(|ra| ra.iter().copied().reduce(f32::max))
1268                        .unwrap_or(0.0)
1269                        .max(item.radius)
1270                        .max(0.01);
1271                    let radius_px = world_r_to_px(ref_pos, max_r).max(8.0);
1272                    if let Some(mut hit) = pick_gaussian_splat_cpu(
1273                        click_pos,
1274                        item.settings.pick_id.0,
1275                        &item.positions,
1276                        glam::Mat4::IDENTITY,
1277                        view_proj,
1278                        viewport_size,
1279                        radius_px,
1280                    ) {
1281                        let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
1282                        if wants_poly_node {
1283                            // sub_object is already SubObjectRef::Point(node_index)
1284                        } else if wants_strip {
1285                            if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
1286                                hit.sub_object = Some(SubObjectRef::Strip(strip_for_node(
1287                                    idx,
1288                                    &item.strip_lengths,
1289                                )));
1290                            }
1291                        } else {
1292                            hit.sub_object = None;
1293                        }
1294                        consider(toi, hit);
1295                    }
1296                }
1297                for item in &self.pick_ribbon_items {
1298                    if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
1299                        continue;
1300                    }
1301                    let ref_pos = glam::Vec3::from(item.positions[0]);
1302                    let radius_px = world_r_to_px(ref_pos, item.width * 0.5).max(8.0);
1303                    if let Some(mut hit) = pick_gaussian_splat_cpu(
1304                        click_pos,
1305                        item.settings.pick_id.0,
1306                        &item.positions,
1307                        glam::Mat4::IDENTITY,
1308                        view_proj,
1309                        viewport_size,
1310                        radius_px,
1311                    ) {
1312                        let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
1313                        if wants_poly_node {
1314                            // sub_object is already SubObjectRef::Point(node_index)
1315                        } else if wants_strip {
1316                            if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
1317                                hit.sub_object = Some(SubObjectRef::Strip(strip_for_node(
1318                                    idx,
1319                                    &item.strip_lengths,
1320                                )));
1321                            }
1322                        } else {
1323                            hit.sub_object = None;
1324                        }
1325                        consider(toi, hit);
1326                    }
1327                }
1328            }
1329
1330            // SEGMENT / STRIP / OBJECT pass using full geometric tests.
1331            if wants_segment || wants_strip || wants_object {
1332                // Streamtube: project each cylinder axis segment to screen and find the
1333                // closest point along the full segment (not just the midpoint).
1334                for item in &self.pick_streamtube_items {
1335                    if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
1336                        continue;
1337                    }
1338                    let ref_pos = glam::Vec3::from(item.positions[0]);
1339                    let threshold_px = world_r_to_px(ref_pos, item.radius.max(0.01));
1340                    let Some((seg_idx, world_pos)) = pick_closest_polyline_segment(
1341                        click_pos,
1342                        viewport_size,
1343                        view_proj,
1344                        &item.positions,
1345                        &item.strip_lengths,
1346                        threshold_px,
1347                    ) else {
1348                        continue;
1349                    };
1350                    let toi = (world_pos - ray_origin).dot(ray_dir).max(0.0);
1351                    let sub_object = if wants_segment {
1352                        Some(SubObjectRef::Segment(seg_idx))
1353                    } else if wants_strip {
1354                        Some(SubObjectRef::Strip(strip_for_segment(
1355                            seg_idx,
1356                            &item.strip_lengths,
1357                        )))
1358                    } else {
1359                        None
1360                    };
1361                    #[allow(deprecated)]
1362                    consider(
1363                        toi,
1364                        PickHit {
1365                            id: item.settings.pick_id.0,
1366                            sub_object,
1367                            world_pos,
1368                            normal: glam::Vec3::Z,
1369                            triangle_index: u32::MAX,
1370                            point_index: None,
1371                            scalar_value: None,
1372                        },
1373                    );
1374                }
1375
1376                // Tube: same as streamtube; uses the conservative max of uniform and
1377                // per-point radii for the screen-space threshold.
1378                for item in &self.pick_tube_items {
1379                    if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
1380                        continue;
1381                    }
1382                    let ref_pos = glam::Vec3::from(item.positions[0]);
1383                    let max_r = item
1384                        .radius_attribute
1385                        .as_ref()
1386                        .and_then(|ra| ra.iter().copied().reduce(f32::max))
1387                        .unwrap_or(0.0)
1388                        .max(item.radius)
1389                        .max(0.01);
1390                    let threshold_px = world_r_to_px(ref_pos, max_r);
1391                    let Some((seg_idx, world_pos)) = pick_closest_polyline_segment(
1392                        click_pos,
1393                        viewport_size,
1394                        view_proj,
1395                        &item.positions,
1396                        &item.strip_lengths,
1397                        threshold_px,
1398                    ) else {
1399                        continue;
1400                    };
1401                    let toi = (world_pos - ray_origin).dot(ray_dir).max(0.0);
1402                    let sub_object = if wants_segment {
1403                        Some(SubObjectRef::Segment(seg_idx))
1404                    } else if wants_strip {
1405                        Some(SubObjectRef::Strip(strip_for_segment(
1406                            seg_idx,
1407                            &item.strip_lengths,
1408                        )))
1409                    } else {
1410                        None
1411                    };
1412                    #[allow(deprecated)]
1413                    consider(
1414                        toi,
1415                        PickHit {
1416                            id: item.settings.pick_id.0,
1417                            sub_object,
1418                            world_pos,
1419                            normal: glam::Vec3::Z,
1420                            triangle_index: u32::MAX,
1421                            point_index: None,
1422                            scalar_value: None,
1423                        },
1424                    );
1425                }
1426
1427                // Ribbon: reconstruct the swept quad per segment (parallel-transport
1428                // lateral frame) and test the ray against both triangles of each quad.
1429                for item in &self.pick_ribbon_items {
1430                    if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
1431                        continue;
1432                    }
1433                    let frames = ribbon_lateral_frames(
1434                        &item.positions,
1435                        &item.strip_lengths,
1436                        item.width,
1437                        item.width_attribute.as_deref(),
1438                        item.twist_attribute.as_deref(),
1439                    );
1440
1441                    let single;
1442                    let strips: &[u32] = if item.strip_lengths.is_empty() {
1443                        single = [item.positions.len() as u32];
1444                        &single
1445                    } else {
1446                        &item.strip_lengths
1447                    };
1448
1449                    let mut best_t = f32::MAX;
1450                    let mut best_seg: Option<(u32, glam::Vec3)> = None;
1451                    let mut node_off = 0usize;
1452                    let mut seg_off = 0u32;
1453
1454                    for &slen in strips {
1455                        let slen = slen as usize;
1456                        for k in 0..slen.saturating_sub(1) {
1457                            let ia = node_off + k;
1458                            let ib = node_off + k + 1;
1459                            let pa = glam::Vec3::from(item.positions[ia]);
1460                            let pb = glam::Vec3::from(item.positions[ib]);
1461                            let (ua, wa) = frames[ia];
1462                            let (ub, wb) = frames[ib];
1463                            // Quad corners: c0/c1 at segment start, c2/c3 at end.
1464                            let c0 = pa + ua * wa; // left  at a
1465                            let c1 = pa - ua * wa; // right at a
1466                            let c2 = pb + ub * wb; // left  at b
1467                            let c3 = pb - ub * wb; // right at b
1468                            // Test 2 triangles, both front and back faces.
1469                            let t = ray_triangle(ray_origin, ray_dir, c0, c1, c2)
1470                                .or_else(|| ray_triangle(ray_origin, ray_dir, c1, c3, c2))
1471                                .or_else(|| ray_triangle(ray_origin, ray_dir, c2, c1, c0))
1472                                .or_else(|| ray_triangle(ray_origin, ray_dir, c2, c3, c1));
1473                            if let Some(t) = t {
1474                                if t < best_t {
1475                                    best_t = t;
1476                                    best_seg = Some((seg_off + k as u32, ray_origin + ray_dir * t));
1477                                }
1478                            }
1479                        }
1480                        seg_off += slen.saturating_sub(1) as u32;
1481                        node_off += slen;
1482                    }
1483
1484                    if let Some((seg_idx, world_pos)) = best_seg {
1485                        let sub_object = if wants_segment {
1486                            Some(SubObjectRef::Segment(seg_idx))
1487                        } else if wants_strip {
1488                            Some(SubObjectRef::Strip(strip_for_segment(
1489                                seg_idx,
1490                                &item.strip_lengths,
1491                            )))
1492                        } else {
1493                            None
1494                        };
1495                        #[allow(deprecated)]
1496                        consider(
1497                            best_t,
1498                            PickHit {
1499                                id: item.settings.pick_id.0,
1500                                sub_object,
1501                                world_pos,
1502                                normal: glam::Vec3::Z,
1503                                triangle_index: u32::MAX,
1504                                point_index: None,
1505                                scalar_value: None,
1506                            },
1507                        );
1508                    }
1509                }
1510            }
1511        }
1512
1513        // 10. Image slice / volume surface slice / screen image object picks (OBJECT only).
1514        if wants_object {
1515            // Image slice: axis-aligned quad ray intersection.
1516            for item in &self.pick_image_slice_items {
1517                if item.settings.pick_id == PickId::NONE {
1518                    continue;
1519                }
1520                let [bmin, bmax] = [item.bbox_min, item.bbox_max];
1521                let t = item.offset;
1522                // Plane normal and position along the axis.
1523                let (axis_idx, plane_pos) = match item.axis {
1524                    SliceAxis::X => (0usize, bmin[0] + t * (bmax[0] - bmin[0])),
1525                    SliceAxis::Y => (1usize, bmin[1] + t * (bmax[1] - bmin[1])),
1526                    SliceAxis::Z => (2usize, bmin[2] + t * (bmax[2] - bmin[2])),
1527                };
1528                let plane_n = {
1529                    let mut n = glam::Vec3::ZERO;
1530                    n[axis_idx] = 1.0;
1531                    n
1532                };
1533                let denom = plane_n.dot(ray_dir);
1534                if denom.abs() < 1e-6 {
1535                    continue;
1536                }
1537                let toi = (plane_pos - ray_origin[axis_idx]) / denom;
1538                if toi <= 0.0 {
1539                    continue;
1540                }
1541                let hit_pos = ray_origin + ray_dir * toi;
1542                // Check that the hit is within the slice quad's other two dimensions.
1543                let in_bounds = (0..3)
1544                    .filter(|&i| i != axis_idx)
1545                    .all(|i| hit_pos[i] >= bmin[i] - 1e-4 && hit_pos[i] <= bmax[i] + 1e-4);
1546                if in_bounds {
1547                    #[allow(deprecated)]
1548                    consider(
1549                        toi,
1550                        PickHit {
1551                            id: item.settings.pick_id.0,
1552                            sub_object: None,
1553                            world_pos: hit_pos,
1554                            normal: plane_n,
1555                            triangle_index: u32::MAX,
1556                            point_index: None,
1557                            scalar_value: None,
1558                        },
1559                    );
1560                }
1561            }
1562
1563            // Volume surface slice: ray/mesh intersection via mesh_store CPU data.
1564            for item in &self.pick_volume_surface_slice_items {
1565                if item.settings.pick_id == PickId::NONE {
1566                    continue;
1567                }
1568                let Some(mesh) = self.resources.mesh_store.get(item.mesh_id) else {
1569                    continue;
1570                };
1571                let (Some(positions), Some(indices)) = (&mesh.cpu_positions, &mesh.cpu_indices)
1572                else {
1573                    continue;
1574                };
1575                let model = glam::Mat4::from_cols_array_2d(&item.model);
1576                let verts: Vec<parry3d::math::Vector> = positions
1577                    .iter()
1578                    .map(|p| {
1579                        let wp = model.transform_point3(glam::Vec3::from(*p));
1580                        parry3d::math::Vector::new(wp.x, wp.y, wp.z)
1581                    })
1582                    .collect();
1583                let tri_indices: Vec<[u32; 3]> = indices
1584                    .chunks(3)
1585                    .filter(|c| c.len() == 3)
1586                    .map(|c| [c[0], c[1], c[2]])
1587                    .collect();
1588                if tri_indices.is_empty() {
1589                    continue;
1590                }
1591                let ray = parry3d::query::Ray::new(
1592                    parry3d::math::Vector::new(ray_origin.x, ray_origin.y, ray_origin.z),
1593                    parry3d::math::Vector::new(ray_dir.x, ray_dir.y, ray_dir.z),
1594                );
1595                if let Ok(trimesh) = parry3d::shape::TriMesh::new(verts, tri_indices) {
1596                    use parry3d::query::RayCast;
1597                    if let Some(hit) = trimesh.cast_ray_and_get_normal(
1598                        &parry3d::math::Pose::identity(),
1599                        &ray,
1600                        f32::MAX,
1601                        true,
1602                    ) {
1603                        let world_pos = ray_origin + ray_dir * hit.time_of_impact;
1604                        let n = hit.normal;
1605                        #[allow(deprecated)]
1606                        consider(
1607                            hit.time_of_impact,
1608                            PickHit {
1609                                id: item.settings.pick_id.0,
1610                                sub_object: None,
1611                                world_pos,
1612                                normal: glam::Vec3::new(n.x, n.y, n.z),
1613                                triangle_index: u32::MAX,
1614                                point_index: None,
1615                                scalar_value: None,
1616                            },
1617                        );
1618                    }
1619                }
1620            }
1621
1622            // Screen image: screen-space rect test. toi=0 so these win over any 3D hit.
1623            for item in &self.pick_screen_image_items {
1624                if item.settings.pick_id == PickId::NONE || item.width == 0 || item.height == 0 {
1625                    continue;
1626                }
1627                let img_w = item.width as f32 * item.scale;
1628                let img_h = item.height as f32 * item.scale;
1629                let (sx, sy) = match item.anchor {
1630                    ImageAnchor::TopLeft => (0.0, 0.0),
1631                    ImageAnchor::TopRight => (viewport_size.x - img_w, 0.0),
1632                    ImageAnchor::BottomLeft => (0.0, viewport_size.y - img_h),
1633                    ImageAnchor::BottomRight => (viewport_size.x - img_w, viewport_size.y - img_h),
1634                    ImageAnchor::Center => (
1635                        (viewport_size.x - img_w) * 0.5,
1636                        (viewport_size.y - img_h) * 0.5,
1637                    ),
1638                };
1639                if click_pos.x >= sx
1640                    && click_pos.x <= sx + img_w
1641                    && click_pos.y >= sy
1642                    && click_pos.y <= sy + img_h
1643                {
1644                    // No meaningful 3D position; place the hit at the near-plane.
1645                    let world_pos = ray_origin + ray_dir * 0.001;
1646                    #[allow(deprecated)]
1647                    consider(
1648                        0.0,
1649                        PickHit {
1650                            id: item.settings.pick_id.0,
1651                            sub_object: None,
1652                            world_pos,
1653                            normal: -ray_dir,
1654                            triangle_index: u32::MAX,
1655                            point_index: None,
1656                            scalar_value: None,
1657                        },
1658                    );
1659                }
1660            }
1661        }
1662
1663        // 11. GPU implicit surface picks (OBJECT only -- no sub-element model).
1664        if wants_object {
1665            for item in &self.pick_implicit_items {
1666                if let Some((toi, world_pos)) = pick_implicit_sdf(ray_origin, ray_dir, item) {
1667                    #[allow(deprecated)]
1668                    consider(
1669                        toi,
1670                        PickHit {
1671                            id: item.id,
1672                            sub_object: None,
1673                            world_pos,
1674                            normal: glam::Vec3::Z,
1675                            triangle_index: u32::MAX,
1676                            point_index: None,
1677                            scalar_value: None,
1678                        },
1679                    );
1680                }
1681            }
1682        }
1683
1684        // 12. GPU marching cubes surface picks (OBJECT only).
1685        if wants_object {
1686            for item in &self.pick_mc_items {
1687                if let Some((toi, world_pos)) = pick_mc_volume(ray_origin, ray_dir, item) {
1688                    #[allow(deprecated)]
1689                    consider(
1690                        toi,
1691                        PickHit {
1692                            id: item.id,
1693                            sub_object: None,
1694                            world_pos,
1695                            normal: glam::Vec3::Z,
1696                            triangle_index: u32::MAX,
1697                            point_index: None,
1698                            scalar_value: None,
1699                        },
1700                    );
1701                }
1702            }
1703        }
1704
1705        // Consult registered item-type plugins after the built-in pickers.
1706        // Each plugin returns its own closest hit; the router compares
1707        // by world-space ray t against the running best.
1708        if !self.item_type_plugins.is_empty() {
1709            let plugin_ray = crate::plugin_api::PickRay {
1710                origin: ray_origin,
1711                direction: ray_dir,
1712            };
1713            for plugin in self.item_type_plugins.values() {
1714                if let Some((t, hit)) = plugin.pick(&plugin_ray) {
1715                    consider(t, hit);
1716                }
1717            }
1718        }
1719
1720        best.map(|(_, hit)| hit)
1721    }
1722
1723    // -----------------------------------------------------------------------
1724    // Unified CPU rect pick : renderer.pick_rect()
1725    // -----------------------------------------------------------------------
1726
1727    /// Pick all items or sub-elements inside a screen-space rectangle.
1728    ///
1729    /// Dispatches across all item types retained from the last `prepare()` call.
1730    /// The `mask` controls which item types and sub-element levels participate.
1731    ///
1732    /// # Arguments
1733    /// * `rect_min`      - top-left corner of the selection rect in viewport pixels
1734    /// * `rect_max`      - bottom-right corner of the selection rect in viewport pixels
1735    /// * `viewport_size` - viewport width x height in pixels
1736    /// * `view_proj`     - combined view x projection matrix from the last frame
1737    /// * `mask`          - which item types and sub-element levels to include
1738    pub fn pick_rect(
1739        &self,
1740        rect_min: glam::Vec2,
1741        rect_max: glam::Vec2,
1742        viewport_size: glam::Vec2,
1743        view_proj: glam::Mat4,
1744        mask: crate::interaction::pick_mask::PickMask,
1745    ) -> PickRectResult {
1746        use crate::interaction::pick_mask::PickMask;
1747        use crate::interaction::sub_object::SubObjectRef;
1748
1749        let mut result = PickRectResult::default();
1750
1751        if viewport_size.x <= 0.0 || viewport_size.y <= 0.0 {
1752            return result;
1753        }
1754
1755        let wants_face = mask.intersects(PickMask::FACE);
1756        let wants_vertex = mask.intersects(PickMask::VERTEX);
1757        let wants_cell = mask.intersects(PickMask::CELL);
1758        let wants_cloud = mask.intersects(PickMask::CLOUD_POINT);
1759        let wants_splat = mask.intersects(PickMask::SPLAT);
1760        let wants_object = mask.intersects(PickMask::OBJECT);
1761
1762        // Build lookup for opaque volume mesh face_to_cell maps.
1763        let vm_cell_map: std::collections::HashMap<u64, &[u32]> = self
1764            .pick_volume_mesh_items
1765            .iter()
1766            .filter(|item| item.settings.pick_id != PickId::NONE && !item.face_to_cell.is_empty())
1767            .map(|item| (item.settings.pick_id.0, item.face_to_cell.as_slice()))
1768            .collect();
1769
1770        // Project a local-space point through mvp and return screen coords,
1771        // or None if the point is behind the camera.
1772        let project = |mvp: glam::Mat4, local: glam::Vec3| -> Option<(f32, f32)> {
1773            let clip = mvp * local.extend(1.0);
1774            if clip.w <= 0.0 {
1775                return None;
1776            }
1777            let sx = (clip.x / clip.w + 1.0) * 0.5 * viewport_size.x;
1778            let sy = (1.0 - clip.y / clip.w) * 0.5 * viewport_size.y;
1779            Some((sx, sy))
1780        };
1781
1782        let in_rect = |sx: f32, sy: f32| -> bool {
1783            sx >= rect_min.x && sx <= rect_max.x && sy >= rect_min.y && sy <= rect_max.y
1784        };
1785
1786        // 1. Surface mesh picks (FACE, VERTEX, CELL, or OBJECT).
1787        if wants_face || wants_vertex || wants_cell || wants_object {
1788            for item in &self.pick_scene_items {
1789                if item.settings.hidden || item.settings.pick_id == PickId::NONE {
1790                    continue;
1791                }
1792                let Some(mesh) = self.resources.mesh_store.get(item.mesh_id) else {
1793                    continue;
1794                };
1795                let (Some(positions), Some(indices)) = (&mesh.cpu_positions, &mesh.cpu_indices)
1796                else {
1797                    continue;
1798                };
1799
1800                let model = glam::Mat4::from_cols_array_2d(&item.model);
1801                let mvp = view_proj * model;
1802                let id = item.settings.pick_id.0;
1803                let mut item_hit = false;
1804
1805                if wants_face {
1806                    for (tri_idx, chunk) in indices.chunks(3).enumerate() {
1807                        if chunk.len() < 3 {
1808                            continue;
1809                        }
1810                        let [i0, i1, i2] =
1811                            [chunk[0] as usize, chunk[1] as usize, chunk[2] as usize];
1812                        if i0 >= positions.len() || i1 >= positions.len() || i2 >= positions.len() {
1813                            continue;
1814                        }
1815                        let centroid = (glam::Vec3::from(positions[i0])
1816                            + glam::Vec3::from(positions[i1])
1817                            + glam::Vec3::from(positions[i2]))
1818                            / 3.0;
1819                        if let Some((sx, sy)) = project(mvp, centroid) {
1820                            if in_rect(sx, sy) {
1821                                result
1822                                    .elements
1823                                    .push((id, SubObjectRef::Face(tri_idx as u32)));
1824                                item_hit = true;
1825                            }
1826                        }
1827                    }
1828                } else if wants_cell {
1829                    // Convert boundary triangle hits to originating cell indices.
1830                    if let Some(f2c) = vm_cell_map.get(&id) {
1831                        let mut seen = std::collections::HashSet::new();
1832                        for (tri_idx, chunk) in indices.chunks(3).enumerate() {
1833                            if chunk.len() < 3 {
1834                                continue;
1835                            }
1836                            let [i0, i1, i2] =
1837                                [chunk[0] as usize, chunk[1] as usize, chunk[2] as usize];
1838                            if i0 >= positions.len()
1839                                || i1 >= positions.len()
1840                                || i2 >= positions.len()
1841                            {
1842                                continue;
1843                            }
1844                            let centroid = (glam::Vec3::from(positions[i0])
1845                                + glam::Vec3::from(positions[i1])
1846                                + glam::Vec3::from(positions[i2]))
1847                                / 3.0;
1848                            if let Some((sx, sy)) = project(mvp, centroid) {
1849                                if in_rect(sx, sy) {
1850                                    if let Some(&ci) = f2c.get(tri_idx) {
1851                                        if seen.insert(ci) {
1852                                            result.elements.push((id, SubObjectRef::Cell(ci)));
1853                                        }
1854                                    }
1855                                    item_hit = true;
1856                                }
1857                            }
1858                        }
1859                    } else if wants_vertex {
1860                        // No cell map; fall through to vertex picking for regular meshes.
1861                        for (vi, pos) in positions.iter().enumerate() {
1862                            if let Some((sx, sy)) = project(mvp, glam::Vec3::from(*pos)) {
1863                                if in_rect(sx, sy) {
1864                                    result.elements.push((id, SubObjectRef::Vertex(vi as u32)));
1865                                    item_hit = true;
1866                                }
1867                            }
1868                        }
1869                    }
1870                } else if wants_vertex {
1871                    for (vi, pos) in positions.iter().enumerate() {
1872                        if let Some((sx, sy)) = project(mvp, glam::Vec3::from(*pos)) {
1873                            if in_rect(sx, sy) {
1874                                result.elements.push((id, SubObjectRef::Vertex(vi as u32)));
1875                                item_hit = true;
1876                            }
1877                        }
1878                    }
1879                } else {
1880                    // OBJECT only: mark as hit if any triangle centroid is in rect.
1881                    'tri_scan: for chunk in indices.chunks(3) {
1882                        if chunk.len() < 3 {
1883                            continue;
1884                        }
1885                        let [i0, i1, i2] =
1886                            [chunk[0] as usize, chunk[1] as usize, chunk[2] as usize];
1887                        if i0 >= positions.len() || i1 >= positions.len() || i2 >= positions.len() {
1888                            continue;
1889                        }
1890                        let centroid = (glam::Vec3::from(positions[i0])
1891                            + glam::Vec3::from(positions[i1])
1892                            + glam::Vec3::from(positions[i2]))
1893                            / 3.0;
1894                        if let Some((sx, sy)) = project(mvp, centroid) {
1895                            if in_rect(sx, sy) {
1896                                item_hit = true;
1897                                break 'tri_scan;
1898                            }
1899                        }
1900                    }
1901                }
1902
1903                if wants_object && item_hit {
1904                    result.objects.push(id);
1905                }
1906            }
1907        }
1908
1909        // 2. Opaque volume mesh cell picks are handled in section 1 above via
1910        // vm_cell_map (face_to_cell conversion on boundary triangle hits).
1911
1912        // 2b. Interior-inclusive cell picks for volume meshes rendering
1913        //     transparently. Items rendering as opaque are handled in section 1
1914        //     above via vm_cell_map (face_to_cell on the boundary surface).
1915        if wants_cell || wants_object {
1916            for item in &self.pick_volume_mesh_items {
1917                if item.settings.pick_id == PickId::NONE
1918                    || item.transparency.is_none()
1919                {
1920                    continue;
1921                }
1922                let Some(data) = item.volume_mesh_data.as_deref() else {
1923                    continue;
1924                };
1925                use crate::resources::volume_mesh::CELL_SENTINEL;
1926                let id = item.settings.pick_id.0;
1927                let mvp = view_proj * glam::Mat4::from_cols_array_2d(&item.model);
1928                let mut item_hit = false;
1929
1930                for (cell_idx, cell) in data.cells.iter().enumerate() {
1931                    let nv: usize = if cell[4] == CELL_SENTINEL {
1932                        4
1933                    } else if cell[5] == CELL_SENTINEL {
1934                        5
1935                    } else if cell[6] == CELL_SENTINEL {
1936                        6
1937                    } else {
1938                        8
1939                    };
1940                    let centroid: glam::Vec3 = cell[..nv]
1941                        .iter()
1942                        .map(|&vi| glam::Vec3::from(data.positions[vi as usize]))
1943                        .sum::<glam::Vec3>()
1944                        / nv as f32;
1945                    if let Some((sx, sy)) = project(mvp, centroid) {
1946                        if in_rect(sx, sy) {
1947                            if wants_cell {
1948                                result
1949                                    .elements
1950                                    .push((id, SubObjectRef::Cell(cell_idx as u32)));
1951                            }
1952                            item_hit = true;
1953                        }
1954                    }
1955                }
1956
1957                if wants_object && item_hit {
1958                    result.objects.push(id);
1959                }
1960            }
1961        }
1962
1963        // 3. Point cloud picks (CLOUD_POINT or OBJECT).
1964        if wants_cloud || wants_object {
1965            for item in &self.pick_point_cloud_items {
1966                if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
1967                    continue;
1968                }
1969                let model = glam::Mat4::from_cols_array_2d(&item.model);
1970                let mvp = view_proj * model;
1971                let id = item.settings.pick_id.0;
1972                let mut item_hit = false;
1973
1974                for (pt_idx, pos) in item.positions.iter().enumerate() {
1975                    if let Some((sx, sy)) = project(mvp, glam::Vec3::from(*pos)) {
1976                        if in_rect(sx, sy) {
1977                            if wants_cloud {
1978                                result
1979                                    .elements
1980                                    .push((id, SubObjectRef::Point(pt_idx as u32)));
1981                            }
1982                            item_hit = true;
1983                        }
1984                    }
1985                }
1986
1987                if wants_object && item_hit {
1988                    result.objects.push(id);
1989                }
1990            }
1991        }
1992
1993        // 4. Volume voxel picks (VOXEL or OBJECT).
1994        let wants_voxel = mask.intersects(PickMask::VOXEL);
1995        if wants_voxel || wants_object {
1996            for item in &self.pick_volume_items {
1997                if item.settings.pick_id == PickId::NONE {
1998                    continue;
1999                }
2000                let Some(vol_data) = item.volume_data.as_deref() else {
2001                    continue;
2002                };
2003                let [nx, ny, nz] = vol_data.dims;
2004                if nx == 0 || ny == 0 || nz == 0 || vol_data.data.is_empty() {
2005                    continue;
2006                }
2007                let model = glam::Mat4::from_cols_array_2d(&item.model);
2008                let mvp = view_proj * model;
2009                let bbox_min = glam::Vec3::from(item.bbox_min);
2010                let bbox_max = glam::Vec3::from(item.bbox_max);
2011                let cell = (bbox_max - bbox_min) / glam::Vec3::new(nx as f32, ny as f32, nz as f32);
2012                let id = item.settings.pick_id.0;
2013                let mut item_hit = false;
2014
2015                for iz in 0..nz {
2016                    for iy in 0..ny {
2017                        for ix in 0..nx {
2018                            let flat = (ix + iy * nx + iz * nx * ny) as usize;
2019                            let scalar = vol_data.data[flat];
2020                            if scalar.is_nan()
2021                                || scalar < item.threshold_min
2022                                || scalar > item.threshold_max
2023                            {
2024                                continue;
2025                            }
2026                            let center = bbox_min
2027                                + cell
2028                                    * glam::Vec3::new(
2029                                        ix as f32 + 0.5,
2030                                        iy as f32 + 0.5,
2031                                        iz as f32 + 0.5,
2032                                    );
2033                            if let Some((sx, sy)) = project(mvp, center) {
2034                                if in_rect(sx, sy) {
2035                                    if wants_voxel {
2036                                        result
2037                                            .elements
2038                                            .push((id, SubObjectRef::Voxel(flat as u32)));
2039                                    }
2040                                    item_hit = true;
2041                                }
2042                            }
2043                        }
2044                    }
2045                }
2046
2047                if wants_object && item_hit {
2048                    result.objects.push(id);
2049                }
2050            }
2051        }
2052
2053        // 5. Gaussian splat picks (SPLAT or OBJECT).
2054        if wants_splat || wants_object {
2055            for item in &self.pick_splat_items {
2056                if item.settings.pick_id == PickId::NONE {
2057                    continue;
2058                }
2059                let Some(gpu_set) = self.resources.gaussian_splat_store.get(item.id.0) else {
2060                    continue;
2061                };
2062                if gpu_set.cpu_positions.is_empty() {
2063                    continue;
2064                }
2065                let model = glam::Mat4::from_cols_array_2d(&item.model);
2066                let mvp = view_proj * model;
2067                let id = item.settings.pick_id.0;
2068                let mut item_hit = false;
2069
2070                for (i, pos) in gpu_set.cpu_positions.iter().enumerate() {
2071                    if let Some((sx, sy)) = project(mvp, glam::Vec3::from(*pos)) {
2072                        if in_rect(sx, sy) {
2073                            if wants_splat {
2074                                result.elements.push((id, SubObjectRef::Splat(i as u32)));
2075                            }
2076                            item_hit = true;
2077                        }
2078                    }
2079                }
2080
2081                if wants_object && item_hit {
2082                    result.objects.push(id);
2083                }
2084            }
2085        }
2086
2087        // 6. Instance picks (INSTANCE or OBJECT) for glyphs, tensor glyphs, sprites.
2088        let wants_instance = mask.intersects(PickMask::INSTANCE);
2089        if wants_instance || wants_object {
2090            // Glyphs
2091            for item in &self.pick_glyph_items {
2092                if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
2093                    continue;
2094                }
2095                let model = glam::Mat4::from_cols_array_2d(&item.model);
2096                let mvp = view_proj * model;
2097                let id = item.settings.pick_id.0;
2098                let mut item_hit = false;
2099                for (i, pos) in item.positions.iter().enumerate() {
2100                    if let Some((sx, sy)) = project(mvp, glam::Vec3::from(*pos)) {
2101                        if in_rect(sx, sy) {
2102                            if wants_instance {
2103                                result.elements.push((id, SubObjectRef::Instance(i as u32)));
2104                            }
2105                            item_hit = true;
2106                        }
2107                    }
2108                }
2109                if wants_object && item_hit {
2110                    result.objects.push(id);
2111                }
2112            }
2113
2114            // Tensor glyphs
2115            for item in &self.pick_tensor_glyph_items {
2116                if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
2117                    continue;
2118                }
2119                let model = glam::Mat4::from_cols_array_2d(&item.model);
2120                let mvp = view_proj * model;
2121                let id = item.settings.pick_id.0;
2122                let mut item_hit = false;
2123                for (i, pos) in item.positions.iter().enumerate() {
2124                    if let Some((sx, sy)) = project(mvp, glam::Vec3::from(*pos)) {
2125                        if in_rect(sx, sy) {
2126                            if wants_instance {
2127                                result.elements.push((id, SubObjectRef::Instance(i as u32)));
2128                            }
2129                            item_hit = true;
2130                        }
2131                    }
2132                }
2133                if wants_object && item_hit {
2134                    result.objects.push(id);
2135                }
2136            }
2137
2138            // Sprites
2139            for item in &self.pick_sprite_items {
2140                if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
2141                    continue;
2142                }
2143                let model = glam::Mat4::from_cols_array_2d(&item.model);
2144                let mvp = view_proj * model;
2145                let id = item.settings.pick_id.0;
2146                let mut item_hit = false;
2147                for (i, pos) in item.positions.iter().enumerate() {
2148                    if let Some((sx, sy)) = project(mvp, glam::Vec3::from(*pos)) {
2149                        if in_rect(sx, sy) {
2150                            if wants_instance {
2151                                result.elements.push((id, SubObjectRef::Instance(i as u32)));
2152                            }
2153                            item_hit = true;
2154                        }
2155                    }
2156                }
2157                if wants_object && item_hit {
2158                    result.objects.push(id);
2159                }
2160            }
2161        }
2162
2163        // 7. Polyline node / segment / strip / object rect picks.
2164        let wants_poly_node = mask.intersects(PickMask::POLY_NODE);
2165        let wants_segment = mask.intersects(PickMask::SEGMENT);
2166        let wants_strip = mask.intersects(PickMask::STRIP);
2167        if wants_poly_node || wants_segment || wants_strip || wants_object {
2168            for item in &self.pick_polyline_items {
2169                if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
2170                    continue;
2171                }
2172                let id = item.settings.pick_id.0;
2173                let mut item_hit = false;
2174                let mut strips_hit = std::collections::HashSet::<u32>::new();
2175
2176                // Node pass (POLY_NODE or STRIP or OBJECT).
2177                if wants_poly_node || wants_strip || wants_object {
2178                    for (node_idx, pos) in item.positions.iter().enumerate() {
2179                        if let Some((sx, sy)) = project(view_proj, glam::Vec3::from(*pos)) {
2180                            if in_rect(sx, sy) {
2181                                item_hit = true;
2182                                if wants_poly_node {
2183                                    result
2184                                        .elements
2185                                        .push((id, SubObjectRef::Point(node_idx as u32)));
2186                                } else if wants_strip {
2187                                    let s = strip_for_node(node_idx as u32, &item.strip_lengths);
2188                                    strips_hit.insert(s);
2189                                }
2190                            }
2191                        }
2192                    }
2193                }
2194
2195                // Segment pass (SEGMENT or STRIP or OBJECT) -- full segment/rect intersection.
2196                if wants_segment || (wants_strip && !wants_poly_node) || wants_object {
2197                    let mut node_off = 0usize;
2198                    let mut seg_off = 0u32;
2199                    macro_rules! try_seg_rect {
2200                        ($ai:expr, $bi:expr, $seg:expr) => {{
2201                            if let (Some((sax, say)), Some((sbx, sby))) = (
2202                                project(view_proj, glam::Vec3::from(item.positions[$ai])),
2203                                project(view_proj, glam::Vec3::from(item.positions[$bi])),
2204                            ) {
2205                                if segment_in_rect(
2206                                    glam::Vec2::new(sax, say),
2207                                    glam::Vec2::new(sbx, sby),
2208                                    rect_min,
2209                                    rect_max,
2210                                ) {
2211                                    item_hit = true;
2212                                    if wants_segment {
2213                                        result.elements.push((id, SubObjectRef::Segment($seg)));
2214                                    } else if wants_strip {
2215                                        let s = strip_for_segment($seg, &item.strip_lengths);
2216                                        strips_hit.insert(s);
2217                                    }
2218                                }
2219                            }
2220                        }};
2221                    }
2222                    if item.strip_lengths.is_empty() {
2223                        for j in 0..item.positions.len().saturating_sub(1) {
2224                            try_seg_rect!(j, j + 1, j as u32);
2225                        }
2226                    } else {
2227                        for &slen in &item.strip_lengths {
2228                            let slen = slen as usize;
2229                            for j in 0..slen.saturating_sub(1) {
2230                                try_seg_rect!(node_off + j, node_off + j + 1, seg_off + j as u32);
2231                            }
2232                            seg_off += slen.saturating_sub(1) as u32;
2233                            node_off += slen;
2234                        }
2235                    }
2236                }
2237
2238                if wants_strip {
2239                    for s in strips_hit {
2240                        result.elements.push((id, SubObjectRef::Strip(s)));
2241                    }
2242                }
2243                if wants_object && item_hit {
2244                    result.objects.push(id);
2245                }
2246            }
2247        }
2248
2249        // 8. Streamtube / tube / ribbon segment / strip / object rect picks.
2250        if wants_poly_node || wants_segment || wants_strip || wants_object {
2251            // Streamtube and tube: test both projected endpoints of each segment
2252            // with segment_in_rect instead of the midpoint projection heuristic.
2253            // POLY_NODE: also check each control point individually.
2254            let st_tube_iter = self
2255                .pick_streamtube_items
2256                .iter()
2257                .map(|it| {
2258                    (
2259                        it.settings.pick_id.0,
2260                        it.positions.as_slice(),
2261                        it.strip_lengths.as_slice(),
2262                    )
2263                })
2264                .chain(self.pick_tube_items.iter().map(|it| {
2265                    (
2266                        it.settings.pick_id.0,
2267                        it.positions.as_slice(),
2268                        it.strip_lengths.as_slice(),
2269                    )
2270                }));
2271
2272            for (id, positions, strip_lengths) in st_tube_iter {
2273                if id == 0 || positions.is_empty() {
2274                    continue;
2275                }
2276                let mut item_hit = false;
2277                let mut strips_hit = std::collections::HashSet::<u32>::new();
2278
2279                let single_st;
2280                let strips_st: &[u32] = if strip_lengths.is_empty() {
2281                    single_st = [positions.len() as u32];
2282                    &single_st
2283                } else {
2284                    strip_lengths
2285                };
2286
2287                // POLY_NODE pass: project each control point and check in_rect.
2288                if wants_poly_node || wants_strip || wants_object {
2289                    'st_nodes: for (ni, pos) in positions.iter().enumerate() {
2290                        if let Some((sx, sy)) = project(view_proj, glam::Vec3::from(*pos)) {
2291                            if in_rect(sx, sy) {
2292                                item_hit = true;
2293                                if wants_poly_node {
2294                                    result.elements.push((id, SubObjectRef::Point(ni as u32)));
2295                                } else if wants_strip {
2296                                    let s = strip_for_node(ni as u32, strip_lengths);
2297                                    strips_hit.insert(s);
2298                                } else {
2299                                    // wants_object only: no need to enumerate further nodes.
2300                                    break 'st_nodes;
2301                                }
2302                            }
2303                        }
2304                    }
2305                }
2306
2307                // SEGMENT pass: test both projected endpoints of each segment.
2308                if wants_segment || wants_strip || wants_object {
2309                    let mut node_off = 0usize;
2310                    let mut seg_off = 0u32;
2311                    'st_strips: for &slen in strips_st {
2312                        let slen = slen as usize;
2313                        for j in 0..slen.saturating_sub(1) {
2314                            let seg_idx = seg_off + j as u32;
2315                            let pa = glam::Vec3::from(positions[node_off + j]);
2316                            let pb = glam::Vec3::from(positions[node_off + j + 1]);
2317                            let hit = match (project(view_proj, pa), project(view_proj, pb)) {
2318                                (Some((ax, ay)), Some((bx, by))) => segment_in_rect(
2319                                    glam::Vec2::new(ax, ay),
2320                                    glam::Vec2::new(bx, by),
2321                                    rect_min,
2322                                    rect_max,
2323                                ),
2324                                (Some((ax, ay)), None) => in_rect(ax, ay),
2325                                (None, Some((bx, by))) => in_rect(bx, by),
2326                                (None, None) => false,
2327                            };
2328                            if hit {
2329                                item_hit = true;
2330                                if wants_segment {
2331                                    result.elements.push((id, SubObjectRef::Segment(seg_idx)));
2332                                } else if wants_strip {
2333                                    let s = strip_for_segment(seg_idx, strip_lengths);
2334                                    strips_hit.insert(s);
2335                                } else {
2336                                    // wants_object only: no need to enumerate further segments.
2337                                    break 'st_strips;
2338                                }
2339                            }
2340                        }
2341                        seg_off += slen.saturating_sub(1) as u32;
2342                        node_off += slen;
2343                    }
2344                }
2345
2346                if wants_strip {
2347                    for s in strips_hit {
2348                        result.elements.push((id, SubObjectRef::Strip(s)));
2349                    }
2350                }
2351                if wants_object && item_hit {
2352                    result.objects.push(id);
2353                }
2354            }
2355
2356            // Ribbon: reconstruct the swept quad per segment and test all four
2357            // quad edges with segment_in_rect (also catches quad corners inside
2358            // the rect via the endpoint check inside segment_in_rect).
2359            // POLY_NODE: also check each control point individually.
2360            for item in &self.pick_ribbon_items {
2361                if item.settings.pick_id == PickId::NONE || item.positions.is_empty() {
2362                    continue;
2363                }
2364
2365                let single_r;
2366                let strips_r: &[u32] = if item.strip_lengths.is_empty() {
2367                    single_r = [item.positions.len() as u32];
2368                    &single_r
2369                } else {
2370                    &item.strip_lengths
2371                };
2372
2373                let mut item_hit = false;
2374                let mut strips_hit = std::collections::HashSet::<u32>::new();
2375
2376                // Project a world point to screen Vec2; returns None if behind camera.
2377                let proj2 = |p: glam::Vec3| -> Option<glam::Vec2> {
2378                    project(view_proj, p).map(|(x, y)| glam::Vec2::new(x, y))
2379                };
2380
2381                // POLY_NODE pass: project each control point and check in_rect.
2382                if wants_poly_node || wants_strip || wants_object {
2383                    'rb_nodes: for (ni, pos) in item.positions.iter().enumerate() {
2384                        if let Some((sx, sy)) = project(view_proj, glam::Vec3::from(*pos)) {
2385                            if in_rect(sx, sy) {
2386                                item_hit = true;
2387                                if wants_poly_node {
2388                                    result.elements.push((
2389                                        item.settings.pick_id.0,
2390                                        SubObjectRef::Point(ni as u32),
2391                                    ));
2392                                } else if wants_strip {
2393                                    let s = strip_for_node(ni as u32, &item.strip_lengths);
2394                                    strips_hit.insert(s);
2395                                } else {
2396                                    break 'rb_nodes;
2397                                }
2398                            }
2399                        }
2400                    }
2401                }
2402
2403                // SEGMENT pass: quad edge tests using ribbon_lateral_frames.
2404                if wants_segment || wants_strip || wants_object {
2405                    let frames = ribbon_lateral_frames(
2406                        &item.positions,
2407                        &item.strip_lengths,
2408                        item.width,
2409                        item.width_attribute.as_deref(),
2410                        item.twist_attribute.as_deref(),
2411                    );
2412                    let mut node_off = 0usize;
2413                    let mut seg_off = 0u32;
2414
2415                    'rb_strips: for &slen in strips_r {
2416                        let slen = slen as usize;
2417                        for k in 0..slen.saturating_sub(1) {
2418                            let seg_idx = seg_off + k as u32;
2419                            let ia = node_off + k;
2420                            let ib = node_off + k + 1;
2421                            let pa = glam::Vec3::from(item.positions[ia]);
2422                            let pb = glam::Vec3::from(item.positions[ib]);
2423                            let (ua, wa) = frames[ia];
2424                            let (ub, wb) = frames[ib];
2425                            let c0 = pa + ua * wa; // left  at a
2426                            let c1 = pa - ua * wa; // right at a
2427                            let c2 = pb + ub * wb; // left  at b
2428                            let c3 = pb - ub * wb; // right at b
2429                            let sc0 = proj2(c0);
2430                            let sc1 = proj2(c1);
2431                            let sc2 = proj2(c2);
2432                            let sc3 = proj2(c3);
2433                            let edge_hit = |a: Option<glam::Vec2>, b: Option<glam::Vec2>| -> bool {
2434                                match (a, b) {
2435                                    (Some(a), Some(b)) => segment_in_rect(a, b, rect_min, rect_max),
2436                                    (Some(a), None) => in_rect(a.x, a.y),
2437                                    (None, Some(b)) => in_rect(b.x, b.y),
2438                                    (None, None) => false,
2439                                }
2440                            };
2441                            let hit = edge_hit(sc0, sc1)
2442                                || edge_hit(sc2, sc3)
2443                                || edge_hit(sc0, sc2)
2444                                || edge_hit(sc1, sc3);
2445                            if hit {
2446                                item_hit = true;
2447                                if wants_segment {
2448                                    result.elements.push((
2449                                        item.settings.pick_id.0,
2450                                        SubObjectRef::Segment(seg_idx),
2451                                    ));
2452                                } else if wants_strip {
2453                                    let s = strip_for_segment(seg_idx, &item.strip_lengths);
2454                                    strips_hit.insert(s);
2455                                } else {
2456                                    break 'rb_strips;
2457                                }
2458                            }
2459                        }
2460                        seg_off += slen.saturating_sub(1) as u32;
2461                        node_off += slen;
2462                    }
2463                }
2464
2465                if wants_strip {
2466                    for s in strips_hit {
2467                        result
2468                            .elements
2469                            .push((item.settings.pick_id.0, SubObjectRef::Strip(s)));
2470                    }
2471                }
2472                if wants_object && item_hit {
2473                    result.objects.push(item.settings.pick_id.0);
2474                }
2475            }
2476        }
2477
2478        // 9. Image slice / volume surface slice / screen image object rect picks (OBJECT only).
2479        if wants_object {
2480            // Image slice: project all 4 quad corners and check containment/edge intersection.
2481            for item in &self.pick_image_slice_items {
2482                if item.settings.pick_id == PickId::NONE {
2483                    continue;
2484                }
2485                let [bmin, bmax] = [item.bbox_min, item.bbox_max];
2486                let t = item.offset;
2487                let corners: [[f32; 3]; 4] = match item.axis {
2488                    SliceAxis::X => {
2489                        let x = bmin[0] + t * (bmax[0] - bmin[0]);
2490                        [
2491                            [x, bmin[1], bmin[2]],
2492                            [x, bmax[1], bmin[2]],
2493                            [x, bmax[1], bmax[2]],
2494                            [x, bmin[1], bmax[2]],
2495                        ]
2496                    }
2497                    SliceAxis::Y => {
2498                        let y = bmin[1] + t * (bmax[1] - bmin[1]);
2499                        [
2500                            [bmin[0], y, bmin[2]],
2501                            [bmax[0], y, bmin[2]],
2502                            [bmax[0], y, bmax[2]],
2503                            [bmin[0], y, bmax[2]],
2504                        ]
2505                    }
2506                    SliceAxis::Z => {
2507                        let z = bmin[2] + t * (bmax[2] - bmin[2]);
2508                        [
2509                            [bmin[0], bmin[1], z],
2510                            [bmax[0], bmin[1], z],
2511                            [bmax[0], bmax[1], z],
2512                            [bmin[0], bmax[1], z],
2513                        ]
2514                    }
2515                };
2516                let sc: Vec<Option<glam::Vec2>> = corners
2517                    .iter()
2518                    .map(|&c| {
2519                        project(view_proj, glam::Vec3::from(c)).map(|(x, y)| glam::Vec2::new(x, y))
2520                    })
2521                    .collect();
2522                let hit = sc.iter().any(|p| p.map_or(false, |p| in_rect(p.x, p.y)))
2523                    || (0..4).any(|i| {
2524                        let a = sc[i];
2525                        let b = sc[(i + 1) % 4];
2526                        match (a, b) {
2527                            (Some(a), Some(b)) => segment_in_rect(a, b, rect_min, rect_max),
2528                            (Some(a), None) => in_rect(a.x, a.y),
2529                            (None, Some(b)) => in_rect(b.x, b.y),
2530                            (None, None) => false,
2531                        }
2532                    });
2533                if hit {
2534                    result.objects.push(item.settings.pick_id.0);
2535                }
2536            }
2537
2538            // Volume surface slice: project each mesh vertex (with model transform) and check.
2539            for item in &self.pick_volume_surface_slice_items {
2540                if item.settings.pick_id == PickId::NONE {
2541                    continue;
2542                }
2543                let Some(mesh) = self.resources.mesh_store.get(item.mesh_id) else {
2544                    continue;
2545                };
2546                let Some(positions) = &mesh.cpu_positions else {
2547                    continue;
2548                };
2549                let model = glam::Mat4::from_cols_array_2d(&item.model);
2550                let hit = positions.iter().any(|&p| {
2551                    let wp = model.transform_point3(glam::Vec3::from(p));
2552                    project(view_proj, wp).map_or(false, |(sx, sy)| in_rect(sx, sy))
2553                });
2554                if hit {
2555                    result.objects.push(item.settings.pick_id.0);
2556                }
2557            }
2558
2559            // Screen image: check if the image's screen rect overlaps the pick rect.
2560            for item in &self.pick_screen_image_items {
2561                if item.settings.pick_id == PickId::NONE || item.width == 0 || item.height == 0 {
2562                    continue;
2563                }
2564                let img_w = item.width as f32 * item.scale;
2565                let img_h = item.height as f32 * item.scale;
2566                let (sx, sy) = match item.anchor {
2567                    ImageAnchor::TopLeft => (0.0, 0.0),
2568                    ImageAnchor::TopRight => (viewport_size.x - img_w, 0.0),
2569                    ImageAnchor::BottomLeft => (0.0, viewport_size.y - img_h),
2570                    ImageAnchor::BottomRight => (viewport_size.x - img_w, viewport_size.y - img_h),
2571                    ImageAnchor::Center => (
2572                        (viewport_size.x - img_w) * 0.5,
2573                        (viewport_size.y - img_h) * 0.5,
2574                    ),
2575                };
2576                // Overlap: image rect [sx, sx+img_w] x [sy, sy+img_h] vs pick rect.
2577                let overlap = sx <= rect_max.x
2578                    && sx + img_w >= rect_min.x
2579                    && sy <= rect_max.y
2580                    && sy + img_h >= rect_min.y;
2581                if overlap {
2582                    result.objects.push(item.settings.pick_id.0);
2583                }
2584            }
2585        }
2586
2587        // 11. GPU implicit surface rect picks (OBJECT only).
2588        //
2589        // For each primitive compute a conservative screen-space AABB by projecting
2590        // the primitive's bounding sphere. If any projected AABB corner falls inside
2591        // the pick rect, the item is a hit. This is approximate (the actual rendered
2592        // surface may be smaller) but avoids per-pixel SDF marching for rect queries.
2593        if wants_object {
2594            for item in &self.pick_implicit_items {
2595                let mut hit = false;
2596                'prim_loop: for prim in &item.primitives {
2597                    // Derive a bounding sphere center and radius for each primitive.
2598                    let (center, radius) = match prim.kind {
2599                        1 => {
2600                            // Sphere
2601                            let c = glam::Vec3::new(prim.params[0], prim.params[1], prim.params[2]);
2602                            (c, prim.params[3].abs())
2603                        }
2604                        2 => {
2605                            // Box: center + max half-extent as radius
2606                            let c = glam::Vec3::new(prim.params[0], prim.params[1], prim.params[2]);
2607                            let h = glam::Vec3::new(prim.params[4], prim.params[5], prim.params[6]);
2608                            (c, h.length())
2609                        }
2610                        3 => {
2611                            // Plane: not bounded -- skip.
2612                            continue;
2613                        }
2614                        4 => {
2615                            // Capsule: midpoint of segment + (half-length + radius)
2616                            let a = glam::Vec3::new(prim.params[0], prim.params[1], prim.params[2]);
2617                            let b = glam::Vec3::new(prim.params[4], prim.params[5], prim.params[6]);
2618                            let r = prim.params[3].abs();
2619                            ((a + b) * 0.5, (b - a).length() * 0.5 + r)
2620                        }
2621                        _ => continue,
2622                    };
2623                    // Project 8 AABB corners of the bounding sphere box.
2624                    for dx in [-radius, radius] {
2625                        for dy in [-radius, radius] {
2626                            for dz in [-radius, radius] {
2627                                let corner = center + glam::Vec3::new(dx, dy, dz);
2628                                if let Some((sx, sy)) = project(view_proj, corner) {
2629                                    if in_rect(sx, sy) {
2630                                        hit = true;
2631                                        break 'prim_loop;
2632                                    }
2633                                }
2634                            }
2635                        }
2636                    }
2637                }
2638                if hit {
2639                    result.objects.push(item.id);
2640                }
2641            }
2642        }
2643
2644        // 12. GPU marching cubes surface rect picks (OBJECT only).
2645        //
2646        // Iterates over all cells in the volume where the scalar field straddles
2647        // the isovalue (MC would generate triangles there). If any such cell's
2648        // center projects into the pick rect, the item is a hit.
2649        if wants_object {
2650            for item in &self.pick_mc_items {
2651                let vol = &item.volume_data;
2652                let isovalue = item.isovalue;
2653                let [nx, ny, nz] = vol.dims;
2654                let origin = glam::Vec3::from(vol.origin);
2655                let spacing = glam::Vec3::from(vol.spacing);
2656
2657                let mut hit = false;
2658                'mc_rect: for iz in 0..nz.saturating_sub(1) {
2659                    for iy in 0..ny.saturating_sub(1) {
2660                        for ix in 0..nx.saturating_sub(1) {
2661                            // A cell straddles the isovalue when not all 8 corners
2662                            // are on the same side. Check for both above and below.
2663                            let mut has_below = false;
2664                            let mut has_above = false;
2665                            'corners: for dz in 0u32..=1 {
2666                                for dy in 0u32..=1 {
2667                                    for dx in 0u32..=1 {
2668                                        let s = vol.sample(ix + dx, iy + dy, iz + dz);
2669                                        if s < isovalue {
2670                                            has_below = true;
2671                                        } else {
2672                                            has_above = true;
2673                                        }
2674                                        if has_below && has_above {
2675                                            break 'corners;
2676                                        }
2677                                    }
2678                                }
2679                            }
2680                            if !(has_below && has_above) {
2681                                continue;
2682                            }
2683                            let cell_center = origin
2684                                + spacing
2685                                    * glam::Vec3::new(
2686                                        ix as f32 + 0.5,
2687                                        iy as f32 + 0.5,
2688                                        iz as f32 + 0.5,
2689                                    );
2690                            if let Some((sx, sy)) = project(view_proj, cell_center) {
2691                                if in_rect(sx, sy) {
2692                                    hit = true;
2693                                    break 'mc_rect;
2694                                }
2695                            }
2696                        }
2697                    }
2698                }
2699                if hit {
2700                    result.objects.push(item.id);
2701                }
2702            }
2703        }
2704
2705        result
2706    }
2707
2708    // -----------------------------------------------------------------------
2709    // GPU object-ID picking
2710    // -----------------------------------------------------------------------
2711
2712    /// GPU object-ID pick: renders the scene to an offscreen `R32Uint` texture
2713    /// and reads back the single pixel under `cursor`.
2714    ///
2715    /// This is O(1) in mesh complexity : every object is rendered with a flat
2716    /// `u32` ID, and only one pixel is read back. For triangle-level queries
2717    /// (barycentric scalar probe, exact world position), use the CPU
2718    /// [`crate::interaction::picking::pick_scene_cpu`] path instead.
2719    ///
2720    /// The pipeline is lazily initialized on first call : zero overhead when
2721    /// this method is never invoked.
2722    ///
2723    /// # Arguments
2724    /// * `device` : wgpu device
2725    /// * `queue` : wgpu queue
2726    /// * `cursor` : cursor position in viewport-local pixels (top-left origin)
2727    /// * `frame` : current grouped frame data (camera, scene surfaces, viewport size)
2728    ///
2729    /// # Returns
2730    /// `Some(GpuPickHit)` if an object is under the cursor, `None` if empty space.
2731    pub fn pick_scene_gpu(
2732        &mut self,
2733        device: &wgpu::Device,
2734        queue: &wgpu::Queue,
2735        cursor: glam::Vec2,
2736        frame: &FrameData,
2737    ) -> Option<crate::interaction::picking::GpuPickHit> {
2738        // In Playback mode, throttle picking to every 4th frame to reduce overhead
2739        // during animation. Interactive, Paused, and Capture modes always pick.
2740        if self.runtime_mode == crate::renderer::stats::RuntimeMode::Playback
2741            && self.frame_counter % 4 != 0
2742        {
2743            return None;
2744        }
2745
2746        // Read scene items from the surface submission.
2747        let scene_items: &[SceneRenderItem] = match &frame.scene.surfaces {
2748            SurfaceSubmission::Flat(items) => items.as_ref(),
2749        };
2750
2751        let ppp = frame.camera.pixels_per_point;
2752        let vp_w = (frame.camera.viewport_size[0] * ppp).round() as u32;
2753        let vp_h = (frame.camera.viewport_size[1] * ppp).round() as u32;
2754
2755        // --- bounds check (logical coordinates match the logical cursor) ---
2756        if cursor.x < 0.0
2757            || cursor.y < 0.0
2758            || cursor.x >= frame.camera.viewport_size[0]
2759            || cursor.y >= frame.camera.viewport_size[1]
2760            || vp_w == 0
2761            || vp_h == 0
2762        {
2763            return None;
2764        }
2765
2766        // --- lazy pipeline init ---
2767        self.resources.ensure_pick_pipeline(device);
2768
2769        // --- build PickInstance data ---
2770        // Only surfaces with a nonzero pick_id participate in picking.
2771        // Clear value 0 means "no hit" (or non-pickable surface).
2772        let pickable_items: Vec<&SceneRenderItem> = scene_items
2773            .iter()
2774            .filter(|item| !item.settings.hidden && item.settings.pick_id != PickId::NONE)
2775            .collect();
2776
2777        let pick_instances: Vec<PickInstance> = pickable_items
2778            .iter()
2779            .map(|item| {
2780                let m = item.model;
2781                PickInstance {
2782                    model_c0: m[0],
2783                    model_c1: m[1],
2784                    model_c2: m[2],
2785                    model_c3: m[3],
2786                    object_id: item.settings.pick_id.0 as u32,
2787                    _pad: [0; 3],
2788                }
2789            })
2790            .collect();
2791
2792        if pick_instances.is_empty() {
2793            return None;
2794        }
2795
2796        // --- pick instance storage buffer + bind group ---
2797        let pick_instance_bytes = bytemuck::cast_slice(&pick_instances);
2798        let pick_instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
2799            label: Some("pick_instance_buf"),
2800            size: pick_instance_bytes.len().max(80) as u64,
2801            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
2802            mapped_at_creation: false,
2803        });
2804        queue.write_buffer(&pick_instance_buf, 0, pick_instance_bytes);
2805
2806        let pick_instance_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
2807            label: Some("pick_instance_bg"),
2808            layout: self
2809                .resources
2810                .pick_bind_group_layout_1
2811                .as_ref()
2812                .expect("ensure_pick_pipeline must be called first"),
2813            entries: &[wgpu::BindGroupEntry {
2814                binding: 0,
2815                resource: pick_instance_buf.as_entire_binding(),
2816            }],
2817        });
2818
2819        // --- pick camera uniform buffer + bind group ---
2820        let camera_uniform = frame.camera.render_camera.camera_uniform();
2821        let camera_bytes = bytemuck::bytes_of(&camera_uniform);
2822        let pick_camera_buf = device.create_buffer(&wgpu::BufferDescriptor {
2823            label: Some("pick_camera_buf"),
2824            size: std::mem::size_of::<CameraUniform>() as u64,
2825            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2826            mapped_at_creation: false,
2827        });
2828        queue.write_buffer(&pick_camera_buf, 0, camera_bytes);
2829
2830        let pick_camera_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
2831            label: Some("pick_camera_bg"),
2832            layout: self
2833                .resources
2834                .pick_camera_bgl
2835                .as_ref()
2836                .expect("ensure_pick_pipeline must be called first"),
2837            entries: &[
2838                wgpu::BindGroupEntry {
2839                    binding: 0,
2840                    resource: pick_camera_buf.as_entire_binding(),
2841                },
2842                wgpu::BindGroupEntry {
2843                    binding: 6,
2844                    resource: self.resources.clip_volume_uniform_buf.as_entire_binding(),
2845                },
2846            ],
2847        });
2848
2849        // --- offscreen pick textures (R32Uint + R32Float) + depth ---
2850        let pick_id_texture = device.create_texture(&wgpu::TextureDescriptor {
2851            label: Some("pick_id_texture"),
2852            size: wgpu::Extent3d {
2853                width: vp_w,
2854                height: vp_h,
2855                depth_or_array_layers: 1,
2856            },
2857            mip_level_count: 1,
2858            sample_count: 1,
2859            dimension: wgpu::TextureDimension::D2,
2860            format: wgpu::TextureFormat::R32Uint,
2861            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
2862            view_formats: &[],
2863        });
2864        let pick_id_view = pick_id_texture.create_view(&wgpu::TextureViewDescriptor::default());
2865
2866        let pick_depth_texture = device.create_texture(&wgpu::TextureDescriptor {
2867            label: Some("pick_depth_colour_texture"),
2868            size: wgpu::Extent3d {
2869                width: vp_w,
2870                height: vp_h,
2871                depth_or_array_layers: 1,
2872            },
2873            mip_level_count: 1,
2874            sample_count: 1,
2875            dimension: wgpu::TextureDimension::D2,
2876            format: wgpu::TextureFormat::R32Float,
2877            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
2878            view_formats: &[],
2879        });
2880        let pick_depth_view =
2881            pick_depth_texture.create_view(&wgpu::TextureViewDescriptor::default());
2882
2883        let depth_stencil_texture = device.create_texture(&wgpu::TextureDescriptor {
2884            label: Some("pick_ds_texture"),
2885            size: wgpu::Extent3d {
2886                width: vp_w,
2887                height: vp_h,
2888                depth_or_array_layers: 1,
2889            },
2890            mip_level_count: 1,
2891            sample_count: 1,
2892            dimension: wgpu::TextureDimension::D2,
2893            format: wgpu::TextureFormat::Depth24PlusStencil8,
2894            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
2895            view_formats: &[],
2896        });
2897        let depth_stencil_view =
2898            depth_stencil_texture.create_view(&wgpu::TextureViewDescriptor::default());
2899
2900        // --- render pass ---
2901        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
2902            label: Some("pick_pass_encoder"),
2903        });
2904        {
2905            let mut pick_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2906                label: Some("pick_pass"),
2907                color_attachments: &[
2908                    Some(wgpu::RenderPassColorAttachment {
2909                        view: &pick_id_view,
2910                        resolve_target: None,
2911                        depth_slice: None,
2912                        ops: wgpu::Operations {
2913                            load: wgpu::LoadOp::Clear(wgpu::Color {
2914                                r: 0.0,
2915                                g: 0.0,
2916                                b: 0.0,
2917                                a: 0.0,
2918                            }),
2919                            store: wgpu::StoreOp::Store,
2920                        },
2921                    }),
2922                    Some(wgpu::RenderPassColorAttachment {
2923                        view: &pick_depth_view,
2924                        resolve_target: None,
2925                        depth_slice: None,
2926                        ops: wgpu::Operations {
2927                            load: wgpu::LoadOp::Clear(wgpu::Color {
2928                                r: 1.0,
2929                                g: 0.0,
2930                                b: 0.0,
2931                                a: 0.0,
2932                            }),
2933                            store: wgpu::StoreOp::Store,
2934                        },
2935                    }),
2936                ],
2937                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
2938                    view: &depth_stencil_view,
2939                    depth_ops: Some(wgpu::Operations {
2940                        load: wgpu::LoadOp::Clear(1.0),
2941                        store: wgpu::StoreOp::Store,
2942                    }),
2943                    stencil_ops: None,
2944                }),
2945                timestamp_writes: None,
2946                occlusion_query_set: None,
2947            });
2948
2949            pick_pass.set_pipeline(
2950                self.resources
2951                    .pick_pipeline
2952                    .as_ref()
2953                    .expect("ensure_pick_pipeline must be called first"),
2954            );
2955            pick_pass.set_bind_group(0, &pick_camera_bg, &[]);
2956            pick_pass.set_bind_group(1, &pick_instance_bg, &[]);
2957
2958            // Draw each pickable item with its instance slot.
2959            // Instance index in the storage buffer = position in pick_instances vec.
2960            for (instance_slot, item) in pickable_items.iter().enumerate() {
2961                let Some(mesh) = self.resources.mesh_store.get(item.mesh_id) else {
2962                    continue;
2963                };
2964                pick_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
2965                pick_pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
2966                let slot = instance_slot as u32;
2967                pick_pass.draw_indexed(0..mesh.index_count, 0, slot..slot + 1);
2968            }
2969        }
2970
2971        // --- copy 1×1 pixels to staging buffers ---
2972        // R32Uint: 4 bytes per pixel, min bytes_per_row = 256 (wgpu alignment)
2973        let bytes_per_row_aligned = 256u32; // wgpu requires multiples of 256
2974
2975        let id_staging = device.create_buffer(&wgpu::BufferDescriptor {
2976            label: Some("pick_id_staging"),
2977            size: bytes_per_row_aligned as u64,
2978            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
2979            mapped_at_creation: false,
2980        });
2981        let depth_staging = device.create_buffer(&wgpu::BufferDescriptor {
2982            label: Some("pick_depth_staging"),
2983            size: bytes_per_row_aligned as u64,
2984            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
2985            mapped_at_creation: false,
2986        });
2987
2988        // Convert logical cursor to physical pixel coordinates for the pick texture readback.
2989        let px = (cursor.x * ppp).round() as u32;
2990        let py = (cursor.y * ppp).round() as u32;
2991
2992        encoder.copy_texture_to_buffer(
2993            wgpu::TexelCopyTextureInfo {
2994                texture: &pick_id_texture,
2995                mip_level: 0,
2996                origin: wgpu::Origin3d { x: px, y: py, z: 0 },
2997                aspect: wgpu::TextureAspect::All,
2998            },
2999            wgpu::TexelCopyBufferInfo {
3000                buffer: &id_staging,
3001                layout: wgpu::TexelCopyBufferLayout {
3002                    offset: 0,
3003                    bytes_per_row: Some(bytes_per_row_aligned),
3004                    rows_per_image: Some(1),
3005                },
3006            },
3007            wgpu::Extent3d {
3008                width: 1,
3009                height: 1,
3010                depth_or_array_layers: 1,
3011            },
3012        );
3013        encoder.copy_texture_to_buffer(
3014            wgpu::TexelCopyTextureInfo {
3015                texture: &pick_depth_texture,
3016                mip_level: 0,
3017                origin: wgpu::Origin3d { x: px, y: py, z: 0 },
3018                aspect: wgpu::TextureAspect::All,
3019            },
3020            wgpu::TexelCopyBufferInfo {
3021                buffer: &depth_staging,
3022                layout: wgpu::TexelCopyBufferLayout {
3023                    offset: 0,
3024                    bytes_per_row: Some(bytes_per_row_aligned),
3025                    rows_per_image: Some(1),
3026                },
3027            },
3028            wgpu::Extent3d {
3029                width: 1,
3030                height: 1,
3031                depth_or_array_layers: 1,
3032            },
3033        );
3034
3035        queue.submit(std::iter::once(encoder.finish()));
3036
3037        // --- map and read ---
3038        let (tx_id, rx_id) = std::sync::mpsc::channel::<Result<(), wgpu::BufferAsyncError>>();
3039        let (tx_dep, rx_dep) = std::sync::mpsc::channel::<Result<(), wgpu::BufferAsyncError>>();
3040        id_staging
3041            .slice(..)
3042            .map_async(wgpu::MapMode::Read, move |r| {
3043                let _ = tx_id.send(r);
3044            });
3045        depth_staging
3046            .slice(..)
3047            .map_async(wgpu::MapMode::Read, move |r| {
3048                let _ = tx_dep.send(r);
3049            });
3050        device
3051            .poll(wgpu::PollType::Wait {
3052                submission_index: None,
3053                timeout: Some(std::time::Duration::from_secs(5)),
3054            })
3055            .unwrap();
3056        let _ = rx_id.recv().unwrap_or(Err(wgpu::BufferAsyncError));
3057        let _ = rx_dep.recv().unwrap_or(Err(wgpu::BufferAsyncError));
3058
3059        let object_id = {
3060            let data = id_staging.slice(..).get_mapped_range();
3061            u32::from_le_bytes([data[0], data[1], data[2], data[3]])
3062        };
3063        id_staging.unmap();
3064
3065        let depth = {
3066            let data = depth_staging.slice(..).get_mapped_range();
3067            f32::from_le_bytes([data[0], data[1], data[2], data[3]])
3068        };
3069        depth_staging.unmap();
3070
3071        // 0 = miss (clear colour or non-pickable surface).
3072        if object_id == 0 {
3073            return None;
3074        }
3075
3076        Some(crate::interaction::picking::GpuPickHit {
3077            object_id: PickId(object_id as u64),
3078            depth,
3079        })
3080    }
3081}