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/// Build a flat list of segment midpoints ordered by global segment index.
144///
145/// Shared by polyline, streamtube, tube, and ribbon click picking.
146/// The index into the returned slice is the global segment index for that item.
147fn build_segment_midpoints(positions: &[[f32; 3]], strip_lengths: &[u32]) -> Vec<[f32; 3]> {
148    let mut midpoints = Vec::new();
149    if strip_lengths.is_empty() {
150        for j in 0..positions.len().saturating_sub(1) {
151            let a = glam::Vec3::from(positions[j]);
152            let b = glam::Vec3::from(positions[j + 1]);
153            midpoints.push(((a + b) * 0.5).to_array());
154        }
155    } else {
156        let mut node_off = 0usize;
157        for &slen in strip_lengths {
158            let slen = slen as usize;
159            for j in 0..slen.saturating_sub(1) {
160                let a = glam::Vec3::from(positions[node_off + j]);
161                let b = glam::Vec3::from(positions[node_off + j + 1]);
162                midpoints.push(((a + b) * 0.5).to_array());
163            }
164            node_off += slen;
165        }
166    }
167    midpoints
168}
169
170// ---------------------------------------------------------------------------
171// PickRectResult
172// ---------------------------------------------------------------------------
173
174/// Result of a [`ViewportRenderer::pick_rect`] call.
175#[derive(Clone, Debug, Default)]
176pub struct PickRectResult {
177    /// IDs of whole items that have geometry inside the pick rect.
178    ///
179    /// Populated when [`crate::interaction::pick_mask::PickMask::OBJECT`] is set.
180    pub objects: Vec<u64>,
181    /// Sub-elements inside the pick rect as `(item_id, sub_object)` pairs.
182    ///
183    /// Populated when any sub-element bit is set in the mask. All entries
184    /// belong to the same geometric dimension when the mask is
185    /// dimension-homogeneous (the common case).
186    pub elements: Vec<(u64, crate::interaction::sub_object::SubObjectRef)>,
187}
188
189impl PickRectResult {
190    /// Returns `true` when no objects or elements were found.
191    pub fn is_empty(&self) -> bool {
192        self.objects.is_empty() && self.elements.is_empty()
193    }
194}
195
196impl ViewportRenderer {
197    // -----------------------------------------------------------------------
198    // Unified CPU pick : renderer.pick()
199    // -----------------------------------------------------------------------
200
201    /// Pick the nearest item or sub-element under `click_pos`.
202    ///
203    /// Dispatches across all item types retained from the last `prepare()` call.
204    /// The `mask` controls which item types and sub-element levels participate.
205    ///
206    /// Returns `None` if nothing matching the mask is under the cursor.
207    ///
208    /// # Arguments
209    /// * `click_pos`     - cursor position in viewport pixels (top-left origin)
210    /// * `viewport_size` - viewport width x height in pixels
211    /// * `view_proj`     - combined view x projection matrix from the last frame
212    /// * `mask`          - which item types and sub-element levels to include
213    ///
214    /// # Example
215    /// ```rust,ignore
216    /// if let Some(hit) = renderer.pick(cursor, vp_size, view_proj, PickMask::FACE) {
217    ///     println!("hit face {:?} on object {}", hit.sub_object, hit.id);
218    /// }
219    /// ```
220    pub fn pick(
221        &self,
222        click_pos: glam::Vec2,
223        viewport_size: glam::Vec2,
224        view_proj: glam::Mat4,
225        mask: crate::interaction::pick_mask::PickMask,
226    ) -> Option<crate::interaction::picking::PickHit> {
227        use crate::interaction::pick_mask::PickMask;
228        use crate::interaction::picking::{
229            screen_to_ray, pick_point_cloud_cpu, pick_gaussian_splat_cpu, pick_volume_cpu,
230            pick_transparent_volume_mesh_cpu, PickHit,
231        };
232        use crate::interaction::sub_object::SubObjectRef;
233        use parry3d::math::{Pose, Vector};
234        use parry3d::query::{Ray, RayCast};
235
236        if viewport_size.x <= 0.0 || viewport_size.y <= 0.0 {
237            return None;
238        }
239
240        let view_proj_inv = view_proj.inverse();
241        let (ray_origin, ray_dir) = screen_to_ray(click_pos, viewport_size, view_proj_inv);
242
243        let wants_face   = mask.intersects(PickMask::FACE);
244        let wants_vertex = mask.intersects(PickMask::VERTEX);
245        let wants_cell   = mask.intersects(PickMask::CELL);
246        let wants_cloud  = mask.intersects(PickMask::CLOUD_POINT);
247        let wants_splat  = mask.intersects(PickMask::SPLAT);
248        let wants_object = mask.intersects(PickMask::OBJECT);
249        let wants_mesh_sub = wants_face || wants_vertex || mask.intersects(PickMask::EDGE);
250
251        // (toi, hit) -- nearest hit so far across all types.
252        let mut best: Option<(f32, PickHit)> = None;
253
254        let mut consider = |toi: f32, hit: PickHit| {
255            if best.as_ref().map_or(true, |(bt, _)| toi < *bt) {
256                best = Some((toi, hit));
257            }
258        };
259
260        // Build lookup for opaque volume mesh face_to_cell maps (used in section 1
261        // to convert surface Face hits to Cell hits).
262        let vm_cell_map: std::collections::HashMap<u64, &[u32]> = self
263            .pick_volume_mesh_items
264            .iter()
265            .filter(|item| item.pick_id != PickId::NONE && !item.face_to_cell.is_empty())
266            .map(|item| (item.pick_id.0, item.face_to_cell.as_slice()))
267            .collect();
268
269        // 1. Surface mesh picks (FACE, VERTEX, EDGE, CELL, or OBJECT fallback).
270        if wants_mesh_sub || wants_cell || wants_object {
271            let ray = Ray::new(
272                Vector::new(ray_origin.x, ray_origin.y, ray_origin.z),
273                Vector::new(ray_dir.x, ray_dir.y, ray_dir.z),
274            );
275            for item in &self.pick_scene_items {
276                if !item.visible || item.pick_id == PickId::NONE {
277                    continue;
278                }
279                let Some(mesh) = self.resources.mesh_store.get(item.mesh_id) else {
280                    continue;
281                };
282                let (Some(positions), Some(indices)) = (&mesh.cpu_positions, &mesh.cpu_indices)
283                else {
284                    continue;
285                };
286
287                let model = glam::Mat4::from_cols_array_2d(&item.model);
288
289                // Bake the full model matrix into vertex positions so that
290                // non-uniform scale is handled correctly.
291                let verts: Vec<Vector> = positions
292                    .iter()
293                    .map(|p| {
294                        let wp = model.transform_point3(glam::Vec3::from(*p));
295                        Vector::new(wp.x, wp.y, wp.z)
296                    })
297                    .collect();
298
299                let tri_indices: Vec<[u32; 3]> = indices
300                    .chunks(3)
301                    .filter(|c| c.len() == 3)
302                    .map(|c| [c[0], c[1], c[2]])
303                    .collect();
304
305                if tri_indices.is_empty() {
306                    continue;
307                }
308
309                match parry3d::shape::TriMesh::new(verts, tri_indices) {
310                    Ok(trimesh) => {
311                        // Vertices are already in world space: use identity pose.
312                        let identity = Pose::identity();
313                        let Some(intersection) =
314                            trimesh.cast_ray_and_get_normal(&identity, &ray, f32::MAX, true)
315                        else {
316                            continue;
317                        };
318                        let toi = intersection.time_of_impact;
319                        let world_pos = ray_origin + ray_dir * toi;
320                        let normal = intersection.normal;
321
322                        let feature_sub = SubObjectRef::from_feature_id(intersection.feature);
323
324                        let sub_object = if wants_face {
325                            feature_sub
326                        } else if wants_cell {
327                            // Convert surface Face hit to originating cell index.
328                            if let Some(f2c) = vm_cell_map.get(&item.pick_id.0) {
329                                match feature_sub {
330                                    Some(SubObjectRef::Face(face_raw)) => {
331                                        let n_tri = indices.len() / 3;
332                                        let face = if (face_raw as usize) >= n_tri {
333                                            face_raw as usize - n_tri
334                                        } else {
335                                            face_raw as usize
336                                        };
337                                        f2c.get(face).map(|&ci| SubObjectRef::Cell(ci))
338                                    }
339                                    other => other,
340                                }
341                            } else {
342                                // No cell map for this item; fall through to object-only.
343                                None
344                            }
345                        } else if wants_vertex {
346                            // Convert face hit to nearest triangle corner.
347                            match feature_sub {
348                                Some(SubObjectRef::Face(face_raw)) => {
349                                    let n_tri = indices.len() / 3;
350                                    let face = if (face_raw as usize) >= n_tri {
351                                        face_raw as usize - n_tri
352                                    } else {
353                                        face_raw as usize
354                                    };
355                                    if face * 3 + 2 < indices.len() {
356                                        let vis = [
357                                            indices[face * 3] as usize,
358                                            indices[face * 3 + 1] as usize,
359                                            indices[face * 3 + 2] as usize,
360                                        ];
361                                        let (best_vi, _) = vis
362                                            .iter()
363                                            .map(|&i| {
364                                                let p = model.transform_point3(
365                                                    glam::Vec3::from(positions[i]),
366                                                );
367                                                (i, p.distance(world_pos))
368                                            })
369                                            .fold((vis[0], f32::MAX), |acc, (i, d)| {
370                                                if d < acc.1 { (i, d) } else { acc }
371                                            });
372                                        Some(SubObjectRef::Vertex(best_vi as u32))
373                                    } else {
374                                        None
375                                    }
376                                }
377                                other => other,
378                            }
379                        } else {
380                            // Object-only: no sub-element.
381                            None
382                        };
383
384                        // Only emit the hit if we produced a meaningful sub-element
385                        // or the caller explicitly asked for object-level hits.
386                        // Without this guard, an EDGE-only mask runs the ray-trimesh
387                        // intersection (because wants_mesh_sub is true) but falls through
388                        // to sub_object=None, producing a spurious object-level hit.
389                        if sub_object.is_some() || wants_object {
390                            #[allow(deprecated)]
391                            let hit = PickHit {
392                                id: item.pick_id.0,
393                                sub_object,
394                                world_pos,
395                                normal,
396                                triangle_index: u32::MAX,
397                                point_index: None,
398                                scalar_value: None,
399                            };
400                            consider(toi, hit);
401                        }
402                    }
403                    Err(e) => {
404                        tracing::warn!(
405                            pick_id = item.pick_id.0,
406                            error = %e,
407                            "TriMesh build failed in renderer.pick()"
408                        );
409                    }
410                }
411            }
412        }
413
414        // 2. Opaque volume mesh cell picks are handled in section 1 above via
415        // vm_cell_map (face_to_cell conversion on surface Face hits).
416
417        // 2b. Transparent volume mesh cell picks (CELL or OBJECT fallback).
418        if wants_cell || wants_object {
419            for item in &self.pick_tvm_items {
420                if item.pick_id == 0 {
421                    continue;
422                }
423                let Some(data) = item.volume_mesh_data.as_deref() else {
424                    continue;
425                };
426                if let Some(mut hit) = pick_transparent_volume_mesh_cpu(
427                    ray_origin,
428                    ray_dir,
429                    item.pick_id,
430                    glam::Mat4::IDENTITY,
431                    data,
432                ) {
433                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
434                    if !wants_cell {
435                        hit.sub_object = None;
436                    }
437                    consider(toi, hit);
438                }
439            }
440        }
441
442        // 3. Point cloud picks (CLOUD_POINT or OBJECT fallback).
443        if wants_cloud || wants_object {
444            for item in &self.pick_point_cloud_items {
445                if item.id == 0 || item.positions.is_empty() {
446                    continue;
447                }
448                let radius_px = item.point_size.max(4.0);
449                if let Some(mut hit) = pick_point_cloud_cpu(
450                    click_pos,
451                    item.id,
452                    item,
453                    view_proj,
454                    viewport_size,
455                    radius_px,
456                ) {
457                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
458                    if !wants_cloud {
459                        hit.sub_object = None;
460                    }
461                    consider(toi, hit);
462                }
463            }
464        }
465
466        // 4. Volume voxel picks (VOXEL or OBJECT fallback).
467        let wants_voxel = mask.intersects(PickMask::VOXEL);
468        if wants_voxel || wants_object {
469            for item in &self.pick_volume_items {
470                if item.pick_id == 0 {
471                    continue;
472                }
473                let Some(vol_data) = item.volume_data.as_deref() else {
474                    continue;
475                };
476                if let Some(mut hit) = pick_volume_cpu(ray_origin, ray_dir, item.pick_id, item, vol_data) {
477                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
478                    if !wants_voxel {
479                        hit.sub_object = None;
480                    }
481                    consider(toi, hit);
482                }
483            }
484        }
485
486        // 5. Gaussian splat picks (SPLAT or OBJECT fallback).
487        if wants_splat || wants_object {
488            for item in &self.pick_splat_items {
489                if item.pick_id == 0 {
490                    continue;
491                }
492                let Some(gpu_set) = self.resources.gaussian_splat_store.get(item.id.0) else {
493                    continue;
494                };
495                if gpu_set.cpu_positions.is_empty() {
496                    continue;
497                }
498                let model = glam::Mat4::from_cols_array_2d(&item.model);
499                // Derive pick radius from the mean per-splat scale so that a
500                // click anywhere inside the visible disc registers as a hit.
501                let mean_max_scale: f32 = if gpu_set.cpu_scales.is_empty() {
502                    0.05
503                } else {
504                    gpu_set.cpu_scales.iter()
505                        .map(|s| s[0].max(s[1]).max(s[2]))
506                        .sum::<f32>()
507                        / gpu_set.cpu_scales.len() as f32
508                };
509                let world_radius = mean_max_scale * 3.0;
510                let center_w = model.transform_point3(glam::Vec3::ZERO);
511                let p0_clip = view_proj * center_w.extend(1.0);
512                let p1_clip = view_proj * (center_w + glam::Vec3::X * world_radius).extend(1.0);
513                let radius_px = if p0_clip.w.abs() > 1e-6 && p1_clip.w.abs() > 1e-6 {
514                    let p0_ndc = glam::Vec2::new(p0_clip.x, p0_clip.y) / p0_clip.w;
515                    let p1_ndc = glam::Vec2::new(p1_clip.x, p1_clip.y) / p1_clip.w;
516                    ((p1_ndc - p0_ndc).length() * 0.5 * viewport_size.x.max(viewport_size.y))
517                        .max(4.0)
518                } else {
519                    world_radius * 100.0
520                };
521                if let Some(mut hit) = pick_gaussian_splat_cpu(
522                    click_pos,
523                    item.pick_id,
524                    &gpu_set.cpu_positions,
525                    model,
526                    view_proj,
527                    viewport_size,
528                    radius_px,
529                ) {
530                    // pick_gaussian_splat_cpu returns SubObjectRef::Point; remap to Splat.
531                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
532                    if wants_splat {
533                        if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
534                            hit.sub_object = Some(SubObjectRef::Splat(idx));
535                        }
536                    } else {
537                        hit.sub_object = None;
538                    }
539                    consider(toi, hit);
540                }
541            }
542        }
543
544        // 6. Instance picks (INSTANCE or OBJECT fallback) for glyphs, tensor glyphs, sprites.
545        let wants_instance = mask.intersects(PickMask::INSTANCE);
546        if wants_instance || wants_object {
547            // Convert a world-space radius at a given world position to a pixel threshold.
548            // Using the actual instance centroid rather than the model origin gives a correct
549            // pixel size when instances are offset far from the model's local origin.
550            let instance_radius_px = |world_center: glam::Vec3, world_r: f32| -> f32 {
551                let p0 = view_proj * world_center.extend(1.0);
552                let p1 = view_proj * (world_center + glam::Vec3::X * world_r).extend(1.0);
553                if p0.w.abs() > 1e-6 && p1.w.abs() > 1e-6 {
554                    let n0 = glam::Vec2::new(p0.x, p0.y) / p0.w;
555                    let n1 = glam::Vec2::new(p1.x, p1.y) / p1.w;
556                    ((n1 - n0).length() * 0.5 * viewport_size.x.max(viewport_size.y)).max(4.0)
557                } else {
558                    (world_r * 100.0_f32).max(4.0)
559                }
560            };
561
562            // Glyphs
563            for item in &self.pick_glyph_items {
564                if item.id == 0 || item.positions.is_empty() {
565                    continue;
566                }
567                let model = glam::Mat4::from_cols_array_2d(&item.model);
568                let full_len = if item.scale_by_magnitude && !item.vectors.is_empty() {
569                    let mean_mag = item.vectors.iter()
570                        .map(|v| glam::Vec3::from(*v).length())
571                        .sum::<f32>() / item.vectors.len() as f32;
572                    (mean_mag * item.scale).max(0.01)
573                } else {
574                    item.scale.max(0.01)
575                };
576                // Test against the midpoint of each arrow (base + half-vector) with
577                // world_r = half-length. This prevents the hit circle from extending a full
578                // arrow-length behind the base when the arrow points away from the camera.
579                let has_vecs = item.vectors.len() == item.positions.len();
580                let midpoints: Vec<[f32; 3]> = item.positions.iter().enumerate().map(|(i, pos)| {
581                    if has_vecs {
582                        let p = glam::Vec3::from(*pos);
583                        let v = glam::Vec3::from(item.vectors[i]);
584                        let len = if item.scale_by_magnitude { v.length() * item.scale } else { item.scale };
585                        (p + v.normalize_or_zero() * len * 0.5).to_array()
586                    } else {
587                        *pos
588                    }
589                }).collect();
590                let n = midpoints.len() as f32;
591                let centroid = model.transform_point3(
592                    midpoints.iter().map(|p| glam::Vec3::from(*p)).sum::<glam::Vec3>() / n
593                );
594                let radius_px = instance_radius_px(centroid, full_len * 0.5);
595                if let Some(mut hit) = pick_gaussian_splat_cpu(
596                    click_pos, item.id, &midpoints, model, view_proj, viewport_size, radius_px,
597                ) {
598                    // Report the base position, not the midpoint.
599                    if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
600                        if let Some(base) = item.positions.get(idx as usize) {
601                            hit.world_pos = model.transform_point3(glam::Vec3::from(*base));
602                        }
603                        if wants_instance {
604                            hit.sub_object = Some(SubObjectRef::Instance(idx));
605                        } else {
606                            hit.sub_object = None;
607                        }
608                    }
609                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
610                    consider(toi, hit);
611                }
612            }
613
614            // Tensor glyphs
615            for item in &self.pick_tensor_glyph_items {
616                if item.id == 0 || item.positions.is_empty() {
617                    continue;
618                }
619                let model = glam::Mat4::from_cols_array_2d(&item.model);
620                // Use the max eigenvalue across all instances so the largest ellipsoid
621                // is fully covered. Use the centroid of instance positions for an accurate
622                // pixel-size estimate (instances may be far from the model origin).
623                let world_r = if !item.eigenvalues.is_empty() {
624                    let max_ev = item.eigenvalues.iter()
625                        .map(|ev| ev[0].abs().max(ev[1].abs()).max(ev[2].abs()))
626                        .fold(0.0_f32, f32::max);
627                    (max_ev * item.scale).max(0.01)
628                } else {
629                    item.scale.max(0.01)
630                };
631                let n = item.positions.len() as f32;
632                let centroid = model.transform_point3(
633                    item.positions.iter().map(|p| glam::Vec3::from(*p)).sum::<glam::Vec3>() / n
634                );
635                let radius_px = instance_radius_px(centroid, world_r);
636                if let Some(mut hit) = pick_gaussian_splat_cpu(
637                    click_pos, item.id, &item.positions, model, view_proj, viewport_size, radius_px,
638                ) {
639                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
640                    if wants_instance {
641                        if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
642                            hit.sub_object = Some(SubObjectRef::Instance(idx));
643                        }
644                    } else {
645                        hit.sub_object = None;
646                    }
647                    consider(toi, hit);
648                }
649            }
650
651            // Sprites
652            for item in &self.pick_sprite_items {
653                if item.id == 0 || item.positions.is_empty() {
654                    continue;
655                }
656                let model = glam::Mat4::from_cols_array_2d(&item.model);
657                let radius_px = match item.size_mode {
658                    SpriteSizeMode::ScreenSpace => (item.default_size * 0.5).max(4.0),
659                    SpriteSizeMode::WorldSpace => {
660                        let n = item.positions.len() as f32;
661                        let centroid = model.transform_point3(
662                            item.positions.iter().map(|p| glam::Vec3::from(*p)).sum::<glam::Vec3>() / n
663                        );
664                        instance_radius_px(centroid, (item.default_size * 0.5).max(0.01))
665                    }
666                };
667                if let Some(mut hit) = pick_gaussian_splat_cpu(
668                    click_pos, item.id, &item.positions, model, view_proj, viewport_size, radius_px,
669                ) {
670                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
671                    if wants_instance {
672                        if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
673                            hit.sub_object = Some(SubObjectRef::Instance(idx));
674                        }
675                    } else {
676                        hit.sub_object = None;
677                    }
678                    consider(toi, hit);
679                }
680            }
681        }
682
683        // 7. Polyline node picks (POLY_NODE, STRIP, or OBJECT fallback).
684        let wants_poly_node = mask.intersects(PickMask::POLY_NODE);
685        let wants_strip = mask.intersects(PickMask::STRIP);
686        if wants_poly_node || wants_strip || wants_object {
687            for item in &self.pick_polyline_items {
688                if item.id == 0 || item.positions.is_empty() {
689                    continue;
690                }
691                let radius_px = (item.line_width + 4.0).max(8.0);
692                if let Some(mut hit) = pick_gaussian_splat_cpu(
693                    click_pos,
694                    item.id,
695                    &item.positions,
696                    glam::Mat4::IDENTITY,
697                    view_proj,
698                    viewport_size,
699                    radius_px,
700                ) {
701                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
702                    if wants_poly_node {
703                        // sub_object is already SubObjectRef::Point(node_index)
704                    } else if wants_strip {
705                        if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
706                            hit.sub_object = Some(SubObjectRef::Strip(
707                                strip_for_node(idx, &item.strip_lengths),
708                            ));
709                        }
710                    } else {
711                        hit.sub_object = None;
712                    }
713                    consider(toi, hit);
714                }
715            }
716        }
717
718        // 8. Polyline segment picks (SEGMENT, STRIP, or OBJECT fallback).
719        // Uses screen-space distance from the click to the full segment line so
720        // clicking anywhere along a segment registers, not just near the midpoint.
721        let wants_segment = mask.intersects(PickMask::SEGMENT);
722        if wants_segment || wants_strip || wants_object {
723            for item in &self.pick_polyline_items {
724                if item.id == 0 || item.positions.is_empty() {
725                    continue;
726                }
727                // Half the visual line width plus a few pixels of slack.
728                let threshold_px = (item.line_width / 2.0 + 4.0).max(4.0);
729                let Some((seg_idx, world_pos)) = pick_closest_polyline_segment(
730                    click_pos,
731                    viewport_size,
732                    view_proj,
733                    &item.positions,
734                    &item.strip_lengths,
735                    threshold_px,
736                ) else {
737                    continue;
738                };
739                let toi = (world_pos - ray_origin).dot(ray_dir).max(0.0);
740                let sub_object = if wants_segment {
741                    Some(SubObjectRef::Segment(seg_idx))
742                } else if wants_strip {
743                    Some(SubObjectRef::Strip(strip_for_segment(seg_idx, &item.strip_lengths)))
744                } else {
745                    None
746                };
747                #[allow(deprecated)]
748                let hit = PickHit {
749                    id: item.id,
750                    sub_object,
751                    world_pos,
752                    normal: glam::Vec3::Y,
753                    triangle_index: u32::MAX,
754                    point_index: None,
755                    scalar_value: None,
756                };
757                consider(toi, hit);
758            }
759        }
760
761        // 9. Streamtube / tube / ribbon segment picks (SEGMENT, STRIP, or OBJECT fallback).
762        if wants_segment || wants_strip || wants_object {
763            // Convert a world-space radius at a reference point to a screen-pixel threshold.
764            let world_r_to_px = |ref_world: glam::Vec3, world_r: f32| -> f32 {
765                let p0 = view_proj * ref_world.extend(1.0);
766                let p1 = view_proj * (ref_world + glam::Vec3::X * world_r).extend(1.0);
767                if p0.w.abs() > 1e-6 && p1.w.abs() > 1e-6 {
768                    let n0 = glam::Vec2::new(p0.x, p0.y) / p0.w;
769                    let n1 = glam::Vec2::new(p1.x, p1.y) / p1.w;
770                    ((n1 - n0).length() * 0.5 * viewport_size.x.max(viewport_size.y)).max(4.0)
771                } else {
772                    (world_r * 100.0_f32).max(4.0)
773                }
774            };
775
776            for item in &self.pick_streamtube_items {
777                if item.id == 0 || item.positions.is_empty() { continue; }
778                let midpoints = build_segment_midpoints(&item.positions, &item.strip_lengths);
779                if midpoints.is_empty() { continue; }
780                let radius_px = world_r_to_px(glam::Vec3::from(midpoints[0]), item.radius.max(0.01));
781                if let Some(mut hit) = pick_gaussian_splat_cpu(
782                    click_pos, item.id, &midpoints, glam::Mat4::IDENTITY, view_proj, viewport_size, radius_px,
783                ) {
784                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
785                    if wants_segment {
786                        if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
787                            hit.sub_object = Some(SubObjectRef::Segment(idx));
788                        }
789                    } else if wants_strip {
790                        if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
791                            hit.sub_object = Some(SubObjectRef::Strip(strip_for_segment(idx, &item.strip_lengths)));
792                        }
793                    } else {
794                        hit.sub_object = None;
795                    }
796                    consider(toi, hit);
797                }
798            }
799
800            for item in &self.pick_tube_items {
801                if item.id == 0 || item.positions.is_empty() { continue; }
802                let midpoints = build_segment_midpoints(&item.positions, &item.strip_lengths);
803                if midpoints.is_empty() { continue; }
804                let radius_px = world_r_to_px(glam::Vec3::from(midpoints[0]), item.radius.max(0.01));
805                if let Some(mut hit) = pick_gaussian_splat_cpu(
806                    click_pos, item.id, &midpoints, glam::Mat4::IDENTITY, view_proj, viewport_size, radius_px,
807                ) {
808                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
809                    if wants_segment {
810                        if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
811                            hit.sub_object = Some(SubObjectRef::Segment(idx));
812                        }
813                    } else if wants_strip {
814                        if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
815                            hit.sub_object = Some(SubObjectRef::Strip(strip_for_segment(idx, &item.strip_lengths)));
816                        }
817                    } else {
818                        hit.sub_object = None;
819                    }
820                    consider(toi, hit);
821                }
822            }
823
824            for item in &self.pick_ribbon_items {
825                if item.id == 0 || item.positions.is_empty() { continue; }
826                let midpoints = build_segment_midpoints(&item.positions, &item.strip_lengths);
827                if midpoints.is_empty() { continue; }
828                let radius_px = world_r_to_px(glam::Vec3::from(midpoints[0]), item.width.max(0.01));
829                if let Some(mut hit) = pick_gaussian_splat_cpu(
830                    click_pos, item.id, &midpoints, glam::Mat4::IDENTITY, view_proj, viewport_size, radius_px,
831                ) {
832                    let toi = (hit.world_pos - ray_origin).dot(ray_dir).max(0.0);
833                    if wants_segment {
834                        if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
835                            hit.sub_object = Some(SubObjectRef::Segment(idx));
836                        }
837                    } else if wants_strip {
838                        if let Some(SubObjectRef::Point(idx)) = hit.sub_object {
839                            hit.sub_object = Some(SubObjectRef::Strip(strip_for_segment(idx, &item.strip_lengths)));
840                        }
841                    } else {
842                        hit.sub_object = None;
843                    }
844                    consider(toi, hit);
845                }
846            }
847        }
848
849        best.map(|(_, hit)| hit)
850    }
851
852    // -----------------------------------------------------------------------
853    // Unified CPU rect pick : renderer.pick_rect()
854    // -----------------------------------------------------------------------
855
856    /// Pick all items or sub-elements inside a screen-space rectangle.
857    ///
858    /// Dispatches across all item types retained from the last `prepare()` call.
859    /// The `mask` controls which item types and sub-element levels participate.
860    ///
861    /// # Arguments
862    /// * `rect_min`      - top-left corner of the selection rect in viewport pixels
863    /// * `rect_max`      - bottom-right corner of the selection rect in viewport pixels
864    /// * `viewport_size` - viewport width x height in pixels
865    /// * `view_proj`     - combined view x projection matrix from the last frame
866    /// * `mask`          - which item types and sub-element levels to include
867    pub fn pick_rect(
868        &self,
869        rect_min: glam::Vec2,
870        rect_max: glam::Vec2,
871        viewport_size: glam::Vec2,
872        view_proj: glam::Mat4,
873        mask: crate::interaction::pick_mask::PickMask,
874    ) -> PickRectResult {
875        use crate::interaction::pick_mask::PickMask;
876        use crate::interaction::sub_object::SubObjectRef;
877
878        let mut result = PickRectResult::default();
879
880        if viewport_size.x <= 0.0 || viewport_size.y <= 0.0 {
881            return result;
882        }
883
884        let wants_face   = mask.intersects(PickMask::FACE);
885        let wants_vertex = mask.intersects(PickMask::VERTEX);
886        let wants_cell   = mask.intersects(PickMask::CELL);
887        let wants_cloud  = mask.intersects(PickMask::CLOUD_POINT);
888        let wants_splat  = mask.intersects(PickMask::SPLAT);
889        let wants_object = mask.intersects(PickMask::OBJECT);
890
891        // Build lookup for opaque volume mesh face_to_cell maps.
892        let vm_cell_map: std::collections::HashMap<u64, &[u32]> = self
893            .pick_volume_mesh_items
894            .iter()
895            .filter(|item| item.pick_id != PickId::NONE && !item.face_to_cell.is_empty())
896            .map(|item| (item.pick_id.0, item.face_to_cell.as_slice()))
897            .collect();
898
899        // Project a local-space point through mvp and return screen coords,
900        // or None if the point is behind the camera.
901        let project = |mvp: glam::Mat4, local: glam::Vec3| -> Option<(f32, f32)> {
902            let clip = mvp * local.extend(1.0);
903            if clip.w <= 0.0 {
904                return None;
905            }
906            let sx = (clip.x / clip.w + 1.0) * 0.5 * viewport_size.x;
907            let sy = (1.0 - clip.y / clip.w) * 0.5 * viewport_size.y;
908            Some((sx, sy))
909        };
910
911        let in_rect = |sx: f32, sy: f32| -> bool {
912            sx >= rect_min.x && sx <= rect_max.x && sy >= rect_min.y && sy <= rect_max.y
913        };
914
915        // 1. Surface mesh picks (FACE, VERTEX, CELL, or OBJECT).
916        if wants_face || wants_vertex || wants_cell || wants_object {
917            for item in &self.pick_scene_items {
918                if !item.visible || item.pick_id == PickId::NONE {
919                    continue;
920                }
921                let Some(mesh) = self.resources.mesh_store.get(item.mesh_id) else {
922                    continue;
923                };
924                let (Some(positions), Some(indices)) =
925                    (&mesh.cpu_positions, &mesh.cpu_indices)
926                else {
927                    continue;
928                };
929
930                let model = glam::Mat4::from_cols_array_2d(&item.model);
931                let mvp = view_proj * model;
932                let id = item.pick_id.0;
933                let mut item_hit = false;
934
935                if wants_face {
936                    for (tri_idx, chunk) in indices.chunks(3).enumerate() {
937                        if chunk.len() < 3 {
938                            continue;
939                        }
940                        let [i0, i1, i2] =
941                            [chunk[0] as usize, chunk[1] as usize, chunk[2] as usize];
942                        if i0 >= positions.len()
943                            || i1 >= positions.len()
944                            || i2 >= positions.len()
945                        {
946                            continue;
947                        }
948                        let centroid = (glam::Vec3::from(positions[i0])
949                            + glam::Vec3::from(positions[i1])
950                            + glam::Vec3::from(positions[i2]))
951                            / 3.0;
952                        if let Some((sx, sy)) = project(mvp, centroid) {
953                            if in_rect(sx, sy) {
954                                result.elements.push((id, SubObjectRef::Face(tri_idx as u32)));
955                                item_hit = true;
956                            }
957                        }
958                    }
959                } else if wants_cell {
960                    // Convert boundary triangle hits to originating cell indices.
961                    if let Some(f2c) = vm_cell_map.get(&id) {
962                        let mut seen = std::collections::HashSet::new();
963                        for (tri_idx, chunk) in indices.chunks(3).enumerate() {
964                            if chunk.len() < 3 {
965                                continue;
966                            }
967                            let [i0, i1, i2] =
968                                [chunk[0] as usize, chunk[1] as usize, chunk[2] as usize];
969                            if i0 >= positions.len()
970                                || i1 >= positions.len()
971                                || i2 >= positions.len()
972                            {
973                                continue;
974                            }
975                            let centroid = (glam::Vec3::from(positions[i0])
976                                + glam::Vec3::from(positions[i1])
977                                + glam::Vec3::from(positions[i2]))
978                                / 3.0;
979                            if let Some((sx, sy)) = project(mvp, centroid) {
980                                if in_rect(sx, sy) {
981                                    if let Some(&ci) = f2c.get(tri_idx) {
982                                        if seen.insert(ci) {
983                                            result.elements.push((id, SubObjectRef::Cell(ci)));
984                                        }
985                                    }
986                                    item_hit = true;
987                                }
988                            }
989                        }
990                    }
991                } else if wants_vertex {
992                    for (vi, pos) in positions.iter().enumerate() {
993                        if let Some((sx, sy)) = project(mvp, glam::Vec3::from(*pos)) {
994                            if in_rect(sx, sy) {
995                                result.elements.push((id, SubObjectRef::Vertex(vi as u32)));
996                                item_hit = true;
997                            }
998                        }
999                    }
1000                } else {
1001                    // OBJECT only: mark as hit if any triangle centroid is in rect.
1002                    'tri_scan: for chunk in indices.chunks(3) {
1003                        if chunk.len() < 3 {
1004                            continue;
1005                        }
1006                        let [i0, i1, i2] =
1007                            [chunk[0] as usize, chunk[1] as usize, chunk[2] as usize];
1008                        if i0 >= positions.len()
1009                            || i1 >= positions.len()
1010                            || i2 >= positions.len()
1011                        {
1012                            continue;
1013                        }
1014                        let centroid = (glam::Vec3::from(positions[i0])
1015                            + glam::Vec3::from(positions[i1])
1016                            + glam::Vec3::from(positions[i2]))
1017                            / 3.0;
1018                        if let Some((sx, sy)) = project(mvp, centroid) {
1019                            if in_rect(sx, sy) {
1020                                item_hit = true;
1021                                break 'tri_scan;
1022                            }
1023                        }
1024                    }
1025                }
1026
1027                if wants_object && item_hit {
1028                    result.objects.push(id);
1029                }
1030            }
1031        }
1032
1033        // 2. Opaque volume mesh cell picks are handled in section 1 above via
1034        // vm_cell_map (face_to_cell conversion on boundary triangle hits).
1035
1036        // 2b. Transparent volume mesh cell picks (CELL or OBJECT).
1037        if wants_cell || wants_object {
1038            for item in &self.pick_tvm_items {
1039                if item.pick_id == 0 {
1040                    continue;
1041                }
1042                let Some(data) = item.volume_mesh_data.as_deref() else {
1043                    continue;
1044                };
1045                use crate::resources::volume_mesh::CELL_SENTINEL;
1046                let id = item.pick_id;
1047                let mvp = view_proj; // TVM items are always in world space (no model transform)
1048                let mut item_hit = false;
1049
1050                for (cell_idx, cell) in data.cells.iter().enumerate() {
1051                    let nv: usize = if cell[4] == CELL_SENTINEL {
1052                        4
1053                    } else if cell[5] == CELL_SENTINEL {
1054                        5
1055                    } else if cell[6] == CELL_SENTINEL {
1056                        6
1057                    } else {
1058                        8
1059                    };
1060                    let centroid: glam::Vec3 = cell[..nv]
1061                        .iter()
1062                        .map(|&vi| glam::Vec3::from(data.positions[vi as usize]))
1063                        .sum::<glam::Vec3>()
1064                        / nv as f32;
1065                    if let Some((sx, sy)) = project(mvp, centroid) {
1066                        if in_rect(sx, sy) {
1067                            if wants_cell {
1068                                result.elements.push((id, SubObjectRef::Cell(cell_idx as u32)));
1069                            }
1070                            item_hit = true;
1071                        }
1072                    }
1073                }
1074
1075                if wants_object && item_hit {
1076                    result.objects.push(id);
1077                }
1078            }
1079        }
1080
1081        // 3. Point cloud picks (CLOUD_POINT or OBJECT).
1082        if wants_cloud || wants_object {
1083            for item in &self.pick_point_cloud_items {
1084                if item.id == 0 || item.positions.is_empty() {
1085                    continue;
1086                }
1087                let model = glam::Mat4::from_cols_array_2d(&item.model);
1088                let mvp = view_proj * model;
1089                let id = item.id;
1090                let mut item_hit = false;
1091
1092                for (pt_idx, pos) in item.positions.iter().enumerate() {
1093                    if let Some((sx, sy)) = project(mvp, glam::Vec3::from(*pos)) {
1094                        if in_rect(sx, sy) {
1095                            if wants_cloud {
1096                                result.elements.push((id, SubObjectRef::Point(pt_idx as u32)));
1097                            }
1098                            item_hit = true;
1099                        }
1100                    }
1101                }
1102
1103                if wants_object && item_hit {
1104                    result.objects.push(id);
1105                }
1106            }
1107        }
1108
1109        // 4. Volume voxel picks (VOXEL or OBJECT).
1110        let wants_voxel = mask.intersects(PickMask::VOXEL);
1111        if wants_voxel || wants_object {
1112            for item in &self.pick_volume_items {
1113                if item.pick_id == 0 {
1114                    continue;
1115                }
1116                let Some(vol_data) = item.volume_data.as_deref() else {
1117                    continue;
1118                };
1119                let [nx, ny, nz] = vol_data.dims;
1120                if nx == 0 || ny == 0 || nz == 0 || vol_data.data.is_empty() {
1121                    continue;
1122                }
1123                let model = glam::Mat4::from_cols_array_2d(&item.model);
1124                let mvp = view_proj * model;
1125                let bbox_min = glam::Vec3::from(item.bbox_min);
1126                let bbox_max = glam::Vec3::from(item.bbox_max);
1127                let cell = (bbox_max - bbox_min)
1128                    / glam::Vec3::new(nx as f32, ny as f32, nz as f32);
1129                let id = item.pick_id;
1130                let mut item_hit = false;
1131
1132                for iz in 0..nz {
1133                    for iy in 0..ny {
1134                        for ix in 0..nx {
1135                            let flat = (ix + iy * nx + iz * nx * ny) as usize;
1136                            let scalar = vol_data.data[flat];
1137                            if scalar.is_nan()
1138                                || scalar < item.threshold_min
1139                                || scalar > item.threshold_max
1140                            {
1141                                continue;
1142                            }
1143                            let center = bbox_min
1144                                + cell * glam::Vec3::new(
1145                                    ix as f32 + 0.5,
1146                                    iy as f32 + 0.5,
1147                                    iz as f32 + 0.5,
1148                                );
1149                            if let Some((sx, sy)) = project(mvp, center) {
1150                                if in_rect(sx, sy) {
1151                                    if wants_voxel {
1152                                        result.elements.push((id, SubObjectRef::Voxel(flat as u32)));
1153                                    }
1154                                    item_hit = true;
1155                                }
1156                            }
1157                        }
1158                    }
1159                }
1160
1161                if wants_object && item_hit {
1162                    result.objects.push(id);
1163                }
1164            }
1165        }
1166
1167        // 5. Gaussian splat picks (SPLAT or OBJECT).
1168        if wants_splat || wants_object {
1169            for item in &self.pick_splat_items {
1170                if item.pick_id == 0 {
1171                    continue;
1172                }
1173                let Some(gpu_set) = self.resources.gaussian_splat_store.get(item.id.0) else {
1174                    continue;
1175                };
1176                if gpu_set.cpu_positions.is_empty() {
1177                    continue;
1178                }
1179                let model = glam::Mat4::from_cols_array_2d(&item.model);
1180                let mvp = view_proj * model;
1181                let id = item.pick_id;
1182                let mut item_hit = false;
1183
1184                for (i, pos) in gpu_set.cpu_positions.iter().enumerate() {
1185                    if let Some((sx, sy)) = project(mvp, glam::Vec3::from(*pos)) {
1186                        if in_rect(sx, sy) {
1187                            if wants_splat {
1188                                result.elements.push((id, SubObjectRef::Splat(i as u32)));
1189                            }
1190                            item_hit = true;
1191                        }
1192                    }
1193                }
1194
1195                if wants_object && item_hit {
1196                    result.objects.push(id);
1197                }
1198            }
1199        }
1200
1201        // 6. Instance picks (INSTANCE or OBJECT) for glyphs, tensor glyphs, sprites.
1202        let wants_instance = mask.intersects(PickMask::INSTANCE);
1203        if wants_instance || wants_object {
1204            // Glyphs
1205            for item in &self.pick_glyph_items {
1206                if item.id == 0 || item.positions.is_empty() {
1207                    continue;
1208                }
1209                let model = glam::Mat4::from_cols_array_2d(&item.model);
1210                let mvp = view_proj * model;
1211                let id = item.id;
1212                let mut item_hit = false;
1213                for (i, pos) in item.positions.iter().enumerate() {
1214                    if let Some((sx, sy)) = project(mvp, glam::Vec3::from(*pos)) {
1215                        if in_rect(sx, sy) {
1216                            if wants_instance {
1217                                result.elements.push((id, SubObjectRef::Instance(i as u32)));
1218                            }
1219                            item_hit = true;
1220                        }
1221                    }
1222                }
1223                if wants_object && item_hit {
1224                    result.objects.push(id);
1225                }
1226            }
1227
1228            // Tensor glyphs
1229            for item in &self.pick_tensor_glyph_items {
1230                if item.id == 0 || item.positions.is_empty() {
1231                    continue;
1232                }
1233                let model = glam::Mat4::from_cols_array_2d(&item.model);
1234                let mvp = view_proj * model;
1235                let id = item.id;
1236                let mut item_hit = false;
1237                for (i, pos) in item.positions.iter().enumerate() {
1238                    if let Some((sx, sy)) = project(mvp, glam::Vec3::from(*pos)) {
1239                        if in_rect(sx, sy) {
1240                            if wants_instance {
1241                                result.elements.push((id, SubObjectRef::Instance(i as u32)));
1242                            }
1243                            item_hit = true;
1244                        }
1245                    }
1246                }
1247                if wants_object && item_hit {
1248                    result.objects.push(id);
1249                }
1250            }
1251
1252            // Sprites
1253            for item in &self.pick_sprite_items {
1254                if item.id == 0 || item.positions.is_empty() {
1255                    continue;
1256                }
1257                let model = glam::Mat4::from_cols_array_2d(&item.model);
1258                let mvp = view_proj * model;
1259                let id = item.id;
1260                let mut item_hit = false;
1261                for (i, pos) in item.positions.iter().enumerate() {
1262                    if let Some((sx, sy)) = project(mvp, glam::Vec3::from(*pos)) {
1263                        if in_rect(sx, sy) {
1264                            if wants_instance {
1265                                result.elements.push((id, SubObjectRef::Instance(i as u32)));
1266                            }
1267                            item_hit = true;
1268                        }
1269                    }
1270                }
1271                if wants_object && item_hit {
1272                    result.objects.push(id);
1273                }
1274            }
1275        }
1276
1277        // 7. Polyline node / segment / strip / object rect picks.
1278        let wants_poly_node = mask.intersects(PickMask::POLY_NODE);
1279        let wants_segment   = mask.intersects(PickMask::SEGMENT);
1280        let wants_strip     = mask.intersects(PickMask::STRIP);
1281        if wants_poly_node || wants_segment || wants_strip || wants_object {
1282            for item in &self.pick_polyline_items {
1283                if item.id == 0 || item.positions.is_empty() {
1284                    continue;
1285                }
1286                let id = item.id;
1287                let mut item_hit = false;
1288                let mut strips_hit = std::collections::HashSet::<u32>::new();
1289
1290                // Node pass (POLY_NODE or STRIP or OBJECT).
1291                if wants_poly_node || wants_strip || wants_object {
1292                    for (node_idx, pos) in item.positions.iter().enumerate() {
1293                        if let Some((sx, sy)) = project(view_proj, glam::Vec3::from(*pos)) {
1294                            if in_rect(sx, sy) {
1295                                item_hit = true;
1296                                if wants_poly_node {
1297                                    result.elements.push((id, SubObjectRef::Point(node_idx as u32)));
1298                                } else if wants_strip {
1299                                    let s = strip_for_node(node_idx as u32, &item.strip_lengths);
1300                                    strips_hit.insert(s);
1301                                }
1302                            }
1303                        }
1304                    }
1305                }
1306
1307                // Segment pass (SEGMENT or STRIP or OBJECT) -- full segment/rect intersection.
1308                if wants_segment || (wants_strip && !wants_poly_node) || wants_object {
1309                    let mut node_off = 0usize;
1310                    let mut seg_off = 0u32;
1311                    macro_rules! try_seg_rect {
1312                        ($ai:expr, $bi:expr, $seg:expr) => {{
1313                            if let (Some((sax, say)), Some((sbx, sby))) = (
1314                                project(view_proj, glam::Vec3::from(item.positions[$ai])),
1315                                project(view_proj, glam::Vec3::from(item.positions[$bi])),
1316                            ) {
1317                                if segment_in_rect(
1318                                    glam::Vec2::new(sax, say),
1319                                    glam::Vec2::new(sbx, sby),
1320                                    rect_min, rect_max,
1321                                ) {
1322                                    item_hit = true;
1323                                    if wants_segment {
1324                                        result.elements.push((id, SubObjectRef::Segment($seg)));
1325                                    } else if wants_strip {
1326                                        let s = strip_for_segment($seg, &item.strip_lengths);
1327                                        strips_hit.insert(s);
1328                                    }
1329                                }
1330                            }
1331                        }};
1332                    }
1333                    if item.strip_lengths.is_empty() {
1334                        for j in 0..item.positions.len().saturating_sub(1) {
1335                            try_seg_rect!(j, j + 1, j as u32);
1336                        }
1337                    } else {
1338                        for &slen in &item.strip_lengths {
1339                            let slen = slen as usize;
1340                            for j in 0..slen.saturating_sub(1) {
1341                                try_seg_rect!(node_off + j, node_off + j + 1, seg_off + j as u32);
1342                            }
1343                            seg_off += slen.saturating_sub(1) as u32;
1344                            node_off += slen;
1345                        }
1346                    }
1347                }
1348
1349                if wants_strip {
1350                    for s in strips_hit {
1351                        result.elements.push((id, SubObjectRef::Strip(s)));
1352                    }
1353                }
1354                if wants_object && item_hit {
1355                    result.objects.push(id);
1356                }
1357            }
1358        }
1359
1360        // 8. Streamtube / tube / ribbon segment / strip / object rect picks.
1361        if wants_segment || wants_strip || wants_object {
1362            let curve_iter = self.pick_streamtube_items.iter()
1363                .map(|it| (it.id, it.positions.as_slice(), it.strip_lengths.as_slice()))
1364                .chain(self.pick_tube_items.iter()
1365                    .map(|it| (it.id, it.positions.as_slice(), it.strip_lengths.as_slice())))
1366                .chain(self.pick_ribbon_items.iter()
1367                    .map(|it| (it.id, it.positions.as_slice(), it.strip_lengths.as_slice())));
1368
1369            for (id, positions, strip_lengths) in curve_iter {
1370                if id == 0 || positions.is_empty() { continue; }
1371                let mut item_hit = false;
1372                let mut strips_hit = std::collections::HashSet::<u32>::new();
1373                // Build indexed midpoints (midpoint world pos, global segment index).
1374                let mut midpoints: Vec<([f32; 3], u32)> = Vec::new();
1375                if strip_lengths.is_empty() {
1376                    for j in 0..positions.len().saturating_sub(1) {
1377                        let a = glam::Vec3::from(positions[j]);
1378                        let b = glam::Vec3::from(positions[j + 1]);
1379                        midpoints.push((((a + b) * 0.5).to_array(), j as u32));
1380                    }
1381                } else {
1382                    let mut node_off = 0usize;
1383                    let mut seg_off = 0u32;
1384                    for &slen in strip_lengths {
1385                        let slen = slen as usize;
1386                        for j in 0..slen.saturating_sub(1) {
1387                            let a = glam::Vec3::from(positions[node_off + j]);
1388                            let b = glam::Vec3::from(positions[node_off + j + 1]);
1389                            midpoints.push((((a + b) * 0.5).to_array(), seg_off + j as u32));
1390                        }
1391                        seg_off += slen.saturating_sub(1) as u32;
1392                        node_off += slen;
1393                    }
1394                }
1395                for (mid, seg_idx) in &midpoints {
1396                    if let Some((sx, sy)) = project(view_proj, glam::Vec3::from(*mid)) {
1397                        if in_rect(sx, sy) {
1398                            item_hit = true;
1399                            if wants_segment {
1400                                result.elements.push((id, SubObjectRef::Segment(*seg_idx)));
1401                            } else if wants_strip {
1402                                let s = strip_for_segment(*seg_idx, strip_lengths);
1403                                strips_hit.insert(s);
1404                            }
1405                        }
1406                    }
1407                }
1408                if wants_strip {
1409                    for s in strips_hit {
1410                        result.elements.push((id, SubObjectRef::Strip(s)));
1411                    }
1412                }
1413                if wants_object && item_hit {
1414                    result.objects.push(id);
1415                }
1416            }
1417        }
1418
1419        result
1420    }
1421
1422    // -----------------------------------------------------------------------
1423    // Phase K : GPU object-ID picking
1424    // -----------------------------------------------------------------------
1425
1426    /// GPU object-ID pick: renders the scene to an offscreen `R32Uint` texture
1427    /// and reads back the single pixel under `cursor`.
1428    ///
1429    /// This is O(1) in mesh complexity : every object is rendered with a flat
1430    /// `u32` ID, and only one pixel is read back. For triangle-level queries
1431    /// (barycentric scalar probe, exact world position), use the CPU
1432    /// [`crate::interaction::picking::pick_scene_cpu`] path instead.
1433    ///
1434    /// The pipeline is lazily initialized on first call : zero overhead when
1435    /// this method is never invoked.
1436    ///
1437    /// # Arguments
1438    /// * `device` : wgpu device
1439    /// * `queue` : wgpu queue
1440    /// * `cursor` : cursor position in viewport-local pixels (top-left origin)
1441    /// * `frame` : current grouped frame data (camera, scene surfaces, viewport size)
1442    ///
1443    /// # Returns
1444    /// `Some(GpuPickHit)` if an object is under the cursor, `None` if empty space.
1445    pub fn pick_scene_gpu(
1446        &mut self,
1447        device: &wgpu::Device,
1448        queue: &wgpu::Queue,
1449        cursor: glam::Vec2,
1450        frame: &FrameData,
1451    ) -> Option<crate::interaction::picking::GpuPickHit> {
1452        // In Playback mode, throttle picking to every 4th frame to reduce overhead
1453        // during animation. Interactive, Paused, and Capture modes always pick.
1454        if self.runtime_mode == crate::renderer::stats::RuntimeMode::Playback
1455            && self.frame_counter % 4 != 0
1456        {
1457            return None;
1458        }
1459
1460        // Read scene items from the surface submission.
1461        let scene_items: &[SceneRenderItem] = match &frame.scene.surfaces {
1462            SurfaceSubmission::Flat(items) => items.as_ref(),
1463        };
1464
1465        let ppp = frame.camera.pixels_per_point;
1466        let vp_w = (frame.camera.viewport_size[0] * ppp).round() as u32;
1467        let vp_h = (frame.camera.viewport_size[1] * ppp).round() as u32;
1468
1469        // --- bounds check (logical coordinates match the logical cursor) ---
1470        if cursor.x < 0.0
1471            || cursor.y < 0.0
1472            || cursor.x >= frame.camera.viewport_size[0]
1473            || cursor.y >= frame.camera.viewport_size[1]
1474            || vp_w == 0
1475            || vp_h == 0
1476        {
1477            return None;
1478        }
1479
1480        // --- lazy pipeline init ---
1481        self.resources.ensure_pick_pipeline(device);
1482
1483        // --- build PickInstance data ---
1484        // Only surfaces with a nonzero pick_id participate in picking.
1485        // Clear value 0 means "no hit" (or non-pickable surface).
1486        let pickable_items: Vec<&SceneRenderItem> = scene_items
1487            .iter()
1488            .filter(|item| item.visible && item.pick_id != PickId::NONE)
1489            .collect();
1490
1491        let pick_instances: Vec<PickInstance> = pickable_items
1492            .iter()
1493            .map(|item| {
1494                let m = item.model;
1495                PickInstance {
1496                    model_c0: m[0],
1497                    model_c1: m[1],
1498                    model_c2: m[2],
1499                    model_c3: m[3],
1500                    object_id: item.pick_id.0 as u32,
1501                    _pad: [0; 3],
1502                }
1503            })
1504            .collect();
1505
1506        if pick_instances.is_empty() {
1507            return None;
1508        }
1509
1510        // --- pick instance storage buffer + bind group ---
1511        let pick_instance_bytes = bytemuck::cast_slice(&pick_instances);
1512        let pick_instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
1513            label: Some("pick_instance_buf"),
1514            size: pick_instance_bytes.len().max(80) as u64,
1515            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
1516            mapped_at_creation: false,
1517        });
1518        queue.write_buffer(&pick_instance_buf, 0, pick_instance_bytes);
1519
1520        let pick_instance_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1521            label: Some("pick_instance_bg"),
1522            layout: self
1523                .resources
1524                .pick_bind_group_layout_1
1525                .as_ref()
1526                .expect("ensure_pick_pipeline must be called first"),
1527            entries: &[wgpu::BindGroupEntry {
1528                binding: 0,
1529                resource: pick_instance_buf.as_entire_binding(),
1530            }],
1531        });
1532
1533        // --- pick camera uniform buffer + bind group ---
1534        let camera_uniform = frame.camera.render_camera.camera_uniform();
1535        let camera_bytes = bytemuck::bytes_of(&camera_uniform);
1536        let pick_camera_buf = device.create_buffer(&wgpu::BufferDescriptor {
1537            label: Some("pick_camera_buf"),
1538            size: std::mem::size_of::<CameraUniform>() as u64,
1539            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1540            mapped_at_creation: false,
1541        });
1542        queue.write_buffer(&pick_camera_buf, 0, camera_bytes);
1543
1544        let pick_camera_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1545            label: Some("pick_camera_bg"),
1546            layout: self
1547                .resources
1548                .pick_camera_bgl
1549                .as_ref()
1550                .expect("ensure_pick_pipeline must be called first"),
1551            entries: &[
1552                wgpu::BindGroupEntry {
1553                    binding: 0,
1554                    resource: pick_camera_buf.as_entire_binding(),
1555                },
1556                wgpu::BindGroupEntry {
1557                    binding: 6,
1558                    resource: self.resources.clip_volume_uniform_buf.as_entire_binding(),
1559                },
1560            ],
1561        });
1562
1563        // --- offscreen pick textures (R32Uint + R32Float) + depth ---
1564        let pick_id_texture = device.create_texture(&wgpu::TextureDescriptor {
1565            label: Some("pick_id_texture"),
1566            size: wgpu::Extent3d {
1567                width: vp_w,
1568                height: vp_h,
1569                depth_or_array_layers: 1,
1570            },
1571            mip_level_count: 1,
1572            sample_count: 1,
1573            dimension: wgpu::TextureDimension::D2,
1574            format: wgpu::TextureFormat::R32Uint,
1575            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
1576            view_formats: &[],
1577        });
1578        let pick_id_view = pick_id_texture.create_view(&wgpu::TextureViewDescriptor::default());
1579
1580        let pick_depth_texture = device.create_texture(&wgpu::TextureDescriptor {
1581            label: Some("pick_depth_color_texture"),
1582            size: wgpu::Extent3d {
1583                width: vp_w,
1584                height: vp_h,
1585                depth_or_array_layers: 1,
1586            },
1587            mip_level_count: 1,
1588            sample_count: 1,
1589            dimension: wgpu::TextureDimension::D2,
1590            format: wgpu::TextureFormat::R32Float,
1591            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
1592            view_formats: &[],
1593        });
1594        let pick_depth_view =
1595            pick_depth_texture.create_view(&wgpu::TextureViewDescriptor::default());
1596
1597        let depth_stencil_texture = device.create_texture(&wgpu::TextureDescriptor {
1598            label: Some("pick_ds_texture"),
1599            size: wgpu::Extent3d {
1600                width: vp_w,
1601                height: vp_h,
1602                depth_or_array_layers: 1,
1603            },
1604            mip_level_count: 1,
1605            sample_count: 1,
1606            dimension: wgpu::TextureDimension::D2,
1607            format: wgpu::TextureFormat::Depth24PlusStencil8,
1608            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1609            view_formats: &[],
1610        });
1611        let depth_stencil_view =
1612            depth_stencil_texture.create_view(&wgpu::TextureViewDescriptor::default());
1613
1614        // --- render pass ---
1615        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1616            label: Some("pick_pass_encoder"),
1617        });
1618        {
1619            let mut pick_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1620                label: Some("pick_pass"),
1621                color_attachments: &[
1622                    Some(wgpu::RenderPassColorAttachment {
1623                        view: &pick_id_view,
1624                        resolve_target: None,
1625                        depth_slice: None,
1626                        ops: wgpu::Operations {
1627                            load: wgpu::LoadOp::Clear(wgpu::Color {
1628                                r: 0.0,
1629                                g: 0.0,
1630                                b: 0.0,
1631                                a: 0.0,
1632                            }),
1633                            store: wgpu::StoreOp::Store,
1634                        },
1635                    }),
1636                    Some(wgpu::RenderPassColorAttachment {
1637                        view: &pick_depth_view,
1638                        resolve_target: None,
1639                        depth_slice: None,
1640                        ops: wgpu::Operations {
1641                            load: wgpu::LoadOp::Clear(wgpu::Color {
1642                                r: 1.0,
1643                                g: 0.0,
1644                                b: 0.0,
1645                                a: 0.0,
1646                            }),
1647                            store: wgpu::StoreOp::Store,
1648                        },
1649                    }),
1650                ],
1651                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1652                    view: &depth_stencil_view,
1653                    depth_ops: Some(wgpu::Operations {
1654                        load: wgpu::LoadOp::Clear(1.0),
1655                        store: wgpu::StoreOp::Store,
1656                    }),
1657                    stencil_ops: None,
1658                }),
1659                timestamp_writes: None,
1660                occlusion_query_set: None,
1661            });
1662
1663            pick_pass.set_pipeline(
1664                self.resources
1665                    .pick_pipeline
1666                    .as_ref()
1667                    .expect("ensure_pick_pipeline must be called first"),
1668            );
1669            pick_pass.set_bind_group(0, &pick_camera_bg, &[]);
1670            pick_pass.set_bind_group(1, &pick_instance_bg, &[]);
1671
1672            // Draw each pickable item with its instance slot.
1673            // Instance index in the storage buffer = position in pick_instances vec.
1674            for (instance_slot, item) in pickable_items.iter().enumerate() {
1675                let Some(mesh) = self
1676                    .resources
1677                    .mesh_store
1678                    .get(item.mesh_id)
1679                else {
1680                    continue;
1681                };
1682                pick_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1683                pick_pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
1684                let slot = instance_slot as u32;
1685                pick_pass.draw_indexed(0..mesh.index_count, 0, slot..slot + 1);
1686            }
1687        }
1688
1689        // --- copy 1×1 pixels to staging buffers ---
1690        // R32Uint: 4 bytes per pixel, min bytes_per_row = 256 (wgpu alignment)
1691        let bytes_per_row_aligned = 256u32; // wgpu requires multiples of 256
1692
1693        let id_staging = device.create_buffer(&wgpu::BufferDescriptor {
1694            label: Some("pick_id_staging"),
1695            size: bytes_per_row_aligned as u64,
1696            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
1697            mapped_at_creation: false,
1698        });
1699        let depth_staging = device.create_buffer(&wgpu::BufferDescriptor {
1700            label: Some("pick_depth_staging"),
1701            size: bytes_per_row_aligned as u64,
1702            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
1703            mapped_at_creation: false,
1704        });
1705
1706        // Convert logical cursor to physical pixel coordinates for the pick texture readback.
1707        let px = (cursor.x * ppp).round() as u32;
1708        let py = (cursor.y * ppp).round() as u32;
1709
1710        encoder.copy_texture_to_buffer(
1711            wgpu::TexelCopyTextureInfo {
1712                texture: &pick_id_texture,
1713                mip_level: 0,
1714                origin: wgpu::Origin3d { x: px, y: py, z: 0 },
1715                aspect: wgpu::TextureAspect::All,
1716            },
1717            wgpu::TexelCopyBufferInfo {
1718                buffer: &id_staging,
1719                layout: wgpu::TexelCopyBufferLayout {
1720                    offset: 0,
1721                    bytes_per_row: Some(bytes_per_row_aligned),
1722                    rows_per_image: Some(1),
1723                },
1724            },
1725            wgpu::Extent3d {
1726                width: 1,
1727                height: 1,
1728                depth_or_array_layers: 1,
1729            },
1730        );
1731        encoder.copy_texture_to_buffer(
1732            wgpu::TexelCopyTextureInfo {
1733                texture: &pick_depth_texture,
1734                mip_level: 0,
1735                origin: wgpu::Origin3d { x: px, y: py, z: 0 },
1736                aspect: wgpu::TextureAspect::All,
1737            },
1738            wgpu::TexelCopyBufferInfo {
1739                buffer: &depth_staging,
1740                layout: wgpu::TexelCopyBufferLayout {
1741                    offset: 0,
1742                    bytes_per_row: Some(bytes_per_row_aligned),
1743                    rows_per_image: Some(1),
1744                },
1745            },
1746            wgpu::Extent3d {
1747                width: 1,
1748                height: 1,
1749                depth_or_array_layers: 1,
1750            },
1751        );
1752
1753        queue.submit(std::iter::once(encoder.finish()));
1754
1755        // --- map and read ---
1756        let (tx_id, rx_id) = std::sync::mpsc::channel::<Result<(), wgpu::BufferAsyncError>>();
1757        let (tx_dep, rx_dep) = std::sync::mpsc::channel::<Result<(), wgpu::BufferAsyncError>>();
1758        id_staging
1759            .slice(..)
1760            .map_async(wgpu::MapMode::Read, move |r| {
1761                let _ = tx_id.send(r);
1762            });
1763        depth_staging
1764            .slice(..)
1765            .map_async(wgpu::MapMode::Read, move |r| {
1766                let _ = tx_dep.send(r);
1767            });
1768        device
1769            .poll(wgpu::PollType::Wait {
1770                submission_index: None,
1771                timeout: Some(std::time::Duration::from_secs(5)),
1772            })
1773            .unwrap();
1774        let _ = rx_id.recv().unwrap_or(Err(wgpu::BufferAsyncError));
1775        let _ = rx_dep.recv().unwrap_or(Err(wgpu::BufferAsyncError));
1776
1777        let object_id = {
1778            let data = id_staging.slice(..).get_mapped_range();
1779            u32::from_le_bytes([data[0], data[1], data[2], data[3]])
1780        };
1781        id_staging.unmap();
1782
1783        let depth = {
1784            let data = depth_staging.slice(..).get_mapped_range();
1785            f32::from_le_bytes([data[0], data[1], data[2], data[3]])
1786        };
1787        depth_staging.unmap();
1788
1789        // 0 = miss (clear color or non-pickable surface).
1790        if object_id == 0 {
1791            return None;
1792        }
1793
1794        Some(crate::interaction::picking::GpuPickHit {
1795            object_id: PickId(object_id as u64),
1796            depth,
1797        })
1798    }
1799}