Skip to main content

viewport_lib/interaction/
picking.rs

1/// Ray-cast picking for the 3D viewport.
2///
3/// Uses parry3d 0.26's glam-native API (no nalgebra required).
4/// All conversions are contained here at the picking boundary.
5use crate::geometry::marching_cubes::VolumeData;
6use crate::interaction::sub_object::SubObjectRef;
7use crate::resources::volume_mesh::{CELL_SENTINEL, VolumeMeshData};
8use crate::resources::{AttributeData, AttributeKind, AttributeRef};
9use crate::scene::traits::ViewportObject;
10use parry3d::math::{Pose, Vector};
11use parry3d::query::{Ray, RayCast};
12
13// ---------------------------------------------------------------------------
14// PickHit : rich hit result
15// ---------------------------------------------------------------------------
16
17/// Result of a successful ray-cast pick against a scene object.
18///
19/// Contains the picked object's ID plus geometric metadata about the hit point.
20/// Use this for snapping, measurement, surface painting, and other hit-dependent features.
21#[derive(Clone, Copy, Debug)]
22#[non_exhaustive]
23pub struct PickHit {
24    /// The object/node ID of the hit.
25    pub id: u64,
26    /// Typed sub-object reference : the authoritative source for sub-object identity.
27    ///
28    /// `Some(SubObjectRef::Face(i))` for mesh picks; `Some(SubObjectRef::Point(i))` for
29    /// point cloud picks; `None` when no specific sub-object could be identified.
30    pub sub_object: Option<SubObjectRef>,
31    /// World-space position of the hit point (`ray_origin + ray_dir * toi`).
32    pub world_pos: glam::Vec3,
33    /// Surface normal at the hit point, in world space.
34    pub normal: glam::Vec3,
35    /// Which triangle was hit (from parry3d `FeatureId::Face`).
36    /// `u32::MAX` if the feature was not a face (edge/vertex hit : rare for TriMesh).
37    ///
38    /// **Deprecated** : use [`sub_object`](Self::sub_object) instead.
39    #[deprecated(since = "0.5.0", note = "use `sub_object` instead")]
40    pub triangle_index: u32,
41    /// Index of the hit point within a [`crate::renderer::PointCloudItem`].
42    /// `None` for mesh picks; set when a point cloud item is hit.
43    ///
44    /// **Deprecated** : use [`sub_object`](Self::sub_object) instead.
45    #[deprecated(since = "0.5.0", note = "use `sub_object` instead")]
46    pub point_index: Option<u32>,
47    /// Interpolated scalar attribute value at the hit point.
48    ///
49    /// Populated by the `_with_probe` picking variants when an active attribute
50    /// is provided. For vertex attributes, the value is barycentric-interpolated
51    /// from the three triangle corner values. For cell attributes, the value is
52    /// read directly from the hit triangle index.
53    pub scalar_value: Option<f32>,
54}
55
56impl PickHit {
57    /// Construct a minimal `PickHit` for cases where no sub-object is identified
58    /// (e.g. volume AABB hits). `normal` is an approximate inward normal.
59    #[allow(deprecated)]
60    pub fn object_hit(id: u64, world_pos: glam::Vec3, normal: glam::Vec3) -> Self {
61        Self {
62            id,
63            sub_object: None,
64            world_pos,
65            normal,
66            triangle_index: u32::MAX,
67            point_index: None,
68            scalar_value: None,
69        }
70    }
71}
72
73// ---------------------------------------------------------------------------
74// GpuPickHit : GPU object-ID pick result
75// ---------------------------------------------------------------------------
76
77/// Result of a GPU object-ID pick pass.
78///
79/// Lighter than [`PickHit`] : carries only the object identifier and the
80/// clip-space depth value at the picked pixel. World position can be
81/// reconstructed from `depth` + the inverse view-projection matrix if needed.
82///
83/// Obtained from [`crate::renderer::ViewportRenderer::pick_scene_gpu`].
84#[derive(Clone, Copy, Debug)]
85#[non_exhaustive]
86pub struct GpuPickHit {
87    /// The `pick_id` of the surface that was hit.
88    ///
89    /// Matches the `SceneRenderItem::pick_id` set by the application.
90    /// Map to a domain object using whatever id-to-object registry the app
91    /// maintains. [`crate::renderer::PickId::NONE`] is never returned
92    /// (non-pickable surfaces are excluded from the pick pass).
93    pub object_id: crate::renderer::PickId,
94    /// Clip-space depth value in `[0, 1]` at the picked pixel.
95    /// `0.0` = near plane, `1.0` = far plane.
96    ///
97    /// Reconstruct world position:
98    /// ```ignore
99    /// let ndc = Vec3::new(ndc_x, ndc_y, hit.depth);
100    /// let world = view_proj_inv.project_point3(ndc);
101    /// ```
102    pub depth: f32,
103}
104
105// ---------------------------------------------------------------------------
106// Public API
107// ---------------------------------------------------------------------------
108
109/// Convert screen position (in viewport-local pixels) to a world-space ray.
110///
111/// Returns (origin, direction) both as glam::Vec3.
112///
113/// # Arguments
114/// * `screen_pos` : mouse position relative to viewport rect top-left
115/// * `viewport_size` : viewport width and height in pixels
116/// * `view_proj_inv` : inverse of (proj * view)
117pub fn screen_to_ray(
118    screen_pos: glam::Vec2,
119    viewport_size: glam::Vec2,
120    view_proj_inv: glam::Mat4,
121) -> (glam::Vec3, glam::Vec3) {
122    let ndc_x = (screen_pos.x / viewport_size.x) * 2.0 - 1.0;
123    let ndc_y = 1.0 - (screen_pos.y / viewport_size.y) * 2.0; // Y flipped (screen Y down, NDC Y up)
124    let near = view_proj_inv.project_point3(glam::Vec3::new(ndc_x, ndc_y, 0.0));
125    let far = view_proj_inv.project_point3(glam::Vec3::new(ndc_x, ndc_y, 1.0));
126    let dir = (far - near).normalize();
127    (near, dir)
128}
129
130/// Cast a ray against all visible viewport objects. Returns a [`PickHit`] for the
131/// nearest hit, or `None` if nothing was hit.
132///
133/// # Arguments
134/// * `ray_origin` : world-space ray origin
135/// * `ray_dir` : world-space ray direction (normalized)
136/// * `objects` : slice of trait objects implementing ViewportObject
137/// * `mesh_lookup` : lookup table: CPU-side positions and indices by mesh_id
138pub fn pick_scene_cpu(
139    ray_origin: glam::Vec3,
140    ray_dir: glam::Vec3,
141    objects: &[&dyn ViewportObject],
142    mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
143) -> Option<PickHit> {
144    // parry3d 0.26 uses glam::Vec3 directly (via glamx)
145    let ray = Ray::new(
146        Vector::new(ray_origin.x, ray_origin.y, ray_origin.z),
147        Vector::new(ray_dir.x, ray_dir.y, ray_dir.z),
148    );
149
150    let mut best_hit: Option<(u64, f32, PickHit)> = None;
151
152    for obj in objects {
153        if !obj.is_visible() {
154            continue;
155        }
156        let Some(mesh_id) = obj.mesh_id() else {
157            continue;
158        };
159
160        if let Some((positions, indices)) = mesh_lookup.get(&mesh_id) {
161            // Build parry3d TriMesh for ray cast test.
162            // parry3d::math::Vector == glam::Vec3 in f32 mode.
163            // Pose only carries translation + rotation, so scale must be baked
164            // into the vertices so the hit shape matches the visual geometry.
165            let s = obj.scale();
166            let verts: Vec<Vector> = positions
167                .iter()
168                .map(|p: &[f32; 3]| Vector::new(p[0] * s.x, p[1] * s.y, p[2] * s.z))
169                .collect();
170
171            let tri_indices: Vec<[u32; 3]> = indices
172                .chunks(3)
173                .filter(|c: &&[u32]| c.len() == 3)
174                .map(|c: &[u32]| [c[0], c[1], c[2]])
175                .collect();
176
177            if tri_indices.is_empty() {
178                continue;
179            }
180
181            match parry3d::shape::TriMesh::new(verts, tri_indices) {
182                Ok(trimesh) => {
183                    // Build pose from object position and rotation.
184                    // cast_ray_and_get_normal with a pose automatically transforms
185                    // the normal into world space.
186                    let pose = Pose::from_parts(obj.position(), obj.rotation());
187                    if let Some(intersection) =
188                        trimesh.cast_ray_and_get_normal(&pose, &ray, f32::MAX, true)
189                    {
190                        let toi = intersection.time_of_impact;
191                        if best_hit.is_none() || toi < best_hit.as_ref().unwrap().1 {
192                            let sub_object = SubObjectRef::from_feature_id(intersection.feature);
193                            let world_pos = ray_origin + ray_dir * toi;
194                            // intersection.normal is already in world space (pose transforms it).
195                            let normal = intersection.normal;
196                            let triangle_index = if let Some(SubObjectRef::Face(i)) = sub_object {
197                                i
198                            } else {
199                                u32::MAX
200                            };
201                            #[allow(deprecated)]
202                            let hit = PickHit {
203                                id: obj.id(),
204                                sub_object,
205                                triangle_index,
206                                world_pos,
207                                normal,
208                                point_index: None,
209                                scalar_value: None,
210                            };
211                            best_hit = Some((obj.id(), toi, hit));
212                        }
213                    }
214                }
215                Err(e) => {
216                    tracing::warn!(object_id = obj.id(), error = %e, "TriMesh construction failed for picking");
217                }
218            }
219        }
220    }
221
222    best_hit.map(|(_, _, hit)| hit)
223}
224
225/// Cast a ray against all visible scene nodes. Returns a [`PickHit`] for the nearest hit.
226///
227/// Same ray-cast logic as `pick_scene_cpu` but reads from `Scene::nodes()` instead
228/// of `&[&dyn ViewportObject]`.
229pub fn pick_scene_nodes_cpu(
230    ray_origin: glam::Vec3,
231    ray_dir: glam::Vec3,
232    scene: &crate::scene::scene::Scene,
233    mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
234) -> Option<PickHit> {
235    let nodes: Vec<&dyn ViewportObject> = scene.nodes().map(|n| n as &dyn ViewportObject).collect();
236    pick_scene_cpu(ray_origin, ray_dir, &nodes, mesh_lookup)
237}
238
239// ---------------------------------------------------------------------------
240// Probe-aware picking : scalar value at hit point
241// ---------------------------------------------------------------------------
242
243/// Per-object attribute binding for probe-aware picking.
244///
245/// Maps an object ID to its active scalar attribute data so that
246/// `pick_scene_with_probe_cpu` can interpolate the scalar value at the hit point.
247pub struct ProbeBinding<'a> {
248    /// Object/node ID this binding applies to.
249    pub id: u64,
250    /// Which attribute is active (name + vertex/cell kind).
251    pub attribute_ref: &'a AttributeRef,
252    /// The raw attribute data (vertex or cell scalars).
253    pub attribute_data: &'a AttributeData,
254    /// CPU-side mesh positions for barycentric computation.
255    pub positions: &'a [[f32; 3]],
256    /// CPU-side mesh indices (triangle list) for vertex lookup.
257    pub indices: &'a [u32],
258}
259
260/// Compute barycentric coordinates of point `p` on the triangle `(a, b, c)`.
261///
262/// Returns `(u, v, w)` where `p ≈ u*a + v*b + w*c` and `u + v + w ≈ 1`.
263/// Uses the robust area-ratio method (Cramer's rule on the edge vectors).
264fn barycentric(p: glam::Vec3, a: glam::Vec3, b: glam::Vec3, c: glam::Vec3) -> (f32, f32, f32) {
265    let v0 = b - a;
266    let v1 = c - a;
267    let v2 = p - a;
268    let d00 = v0.dot(v0);
269    let d01 = v0.dot(v1);
270    let d11 = v1.dot(v1);
271    let d20 = v2.dot(v0);
272    let d21 = v2.dot(v1);
273    let denom = d00 * d11 - d01 * d01;
274    if denom.abs() < 1e-12 {
275        // Degenerate triangle : return equal weights.
276        return (1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0);
277    }
278    let inv = 1.0 / denom;
279    let v = (d11 * d20 - d01 * d21) * inv;
280    let w = (d00 * d21 - d01 * d20) * inv;
281    let u = 1.0 - v - w;
282    (u, v, w)
283}
284
285/// Given a `PickHit` and matching `ProbeBinding`, compute the scalar value at
286/// the hit point and write it into `hit.scalar_value`.
287fn probe_scalar(hit: &mut PickHit, binding: &ProbeBinding<'_>) {
288    let tri_idx_raw = match hit.sub_object {
289        Some(SubObjectRef::Face(i)) => i,
290        _ => return,
291    };
292
293    let num_triangles = binding.indices.len() / 3;
294    // parry3d may return back-face indices (idx >= num_triangles) for solid
295    // meshes. Map them back to the original triangle.
296    let tri_idx = if (tri_idx_raw as usize) >= num_triangles && num_triangles > 0 {
297        tri_idx_raw as usize - num_triangles
298    } else {
299        tri_idx_raw as usize
300    };
301
302    match binding.attribute_ref.kind {
303        AttributeKind::Cell => {
304            // Cell attribute: one value per triangle : use directly.
305            if let AttributeData::Cell(data) = binding.attribute_data {
306                if let Some(&val) = data.get(tri_idx) {
307                    hit.scalar_value = Some(val);
308                }
309            }
310        }
311        AttributeKind::Face => {
312            // Face attribute: one value per triangle : direct lookup (no averaging).
313            if let AttributeData::Face(data) = binding.attribute_data {
314                if let Some(&val) = data.get(tri_idx) {
315                    hit.scalar_value = Some(val);
316                }
317            }
318        }
319        AttributeKind::FaceColour => {
320            // FaceColour attribute: no scalar value to report.
321        }
322        AttributeKind::Vertex => {
323            // Vertex attribute: barycentric interpolation from triangle corners.
324            if let AttributeData::Vertex(data) = binding.attribute_data {
325                let base = tri_idx * 3;
326                if base + 2 >= binding.indices.len() {
327                    return;
328                }
329                let i0 = binding.indices[base] as usize;
330                let i1 = binding.indices[base + 1] as usize;
331                let i2 = binding.indices[base + 2] as usize;
332
333                if i0 >= data.len() || i1 >= data.len() || i2 >= data.len() {
334                    return;
335                }
336                if i0 >= binding.positions.len()
337                    || i1 >= binding.positions.len()
338                    || i2 >= binding.positions.len()
339                {
340                    return;
341                }
342
343                let a = glam::Vec3::from(binding.positions[i0]);
344                let b = glam::Vec3::from(binding.positions[i1]);
345                let c = glam::Vec3::from(binding.positions[i2]);
346                let (u, v, w) = barycentric(hit.world_pos, a, b, c);
347                hit.scalar_value = Some(data[i0] * u + data[i1] * v + data[i2] * w);
348            }
349        }
350        AttributeKind::Edge => {
351            // Edge attribute: use the corner value at the closest triangle vertex
352            // (edge values are already averaged to vertices at upload time).
353            if let AttributeData::Edge(data) = binding.attribute_data {
354                let base = tri_idx * 3;
355                if base + 2 >= binding.indices.len() || data.is_empty() {
356                    return;
357                }
358                let i0 = binding.indices[base] as usize;
359                let i1 = binding.indices[base + 1] as usize;
360                let i2 = binding.indices[base + 2] as usize;
361                if i0 < data.len() || i1 < data.len() || i2 < data.len() {
362                    // Barycentric interpolation over the per-vertex averaged values.
363                    if i0 < data.len()
364                        && i1 < data.len()
365                        && i2 < data.len()
366                        && i0 < binding.positions.len()
367                        && i1 < binding.positions.len()
368                        && i2 < binding.positions.len()
369                    {
370                        let a = glam::Vec3::from(binding.positions[i0]);
371                        let b = glam::Vec3::from(binding.positions[i1]);
372                        let c = glam::Vec3::from(binding.positions[i2]);
373                        let (u, v, w) = barycentric(hit.world_pos, a, b, c);
374                        hit.scalar_value = Some(data[i0] * u + data[i1] * v + data[i2] * w);
375                    }
376                }
377            }
378        }
379        AttributeKind::Halfedge | AttributeKind::Corner => {
380            // Per-corner attributes: `values[3*t + k]` is the k-th corner of the triangle.
381            // Report the value at the nearest corner (flat shading).
382            let extract = |data: &[f32]| -> Option<f32> {
383                let base = tri_idx * 3;
384                if base + 2 >= data.len() {
385                    return None;
386                }
387                // Return the first corner value as the representative (flat per face).
388                Some(data[base])
389            };
390            match binding.attribute_data {
391                AttributeData::Halfedge(data) | AttributeData::Corner(data) => {
392                    hit.scalar_value = extract(data);
393                }
394                _ => {}
395            }
396        }
397    }
398}
399
400/// Like [`pick_scene`] but also computes the scalar attribute value at the hit
401/// point via barycentric interpolation (vertex attributes) or direct lookup
402/// (cell attributes).
403///
404/// `probe_bindings` maps object IDs to their active attribute data. If the hit
405/// object has no matching binding, `PickHit::scalar_value` remains `None`.
406pub fn pick_scene_with_probe_cpu(
407    ray_origin: glam::Vec3,
408    ray_dir: glam::Vec3,
409    objects: &[&dyn ViewportObject],
410    mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
411    probe_bindings: &[ProbeBinding<'_>],
412) -> Option<PickHit> {
413    let mut hit = pick_scene_cpu(ray_origin, ray_dir, objects, mesh_lookup)?;
414    if let Some(binding) = probe_bindings.iter().find(|b| b.id == hit.id) {
415        probe_scalar(&mut hit, binding);
416    }
417    Some(hit)
418}
419
420/// Like [`pick_scene_nodes_cpu`] but also computes the scalar value at the hit point.
421///
422/// See [`pick_scene_with_probe_cpu`] for details on probe bindings.
423pub fn pick_scene_nodes_with_probe_cpu(
424    ray_origin: glam::Vec3,
425    ray_dir: glam::Vec3,
426    scene: &crate::scene::scene::Scene,
427    mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
428    probe_bindings: &[ProbeBinding<'_>],
429) -> Option<PickHit> {
430    let mut hit = pick_scene_nodes_cpu(ray_origin, ray_dir, scene, mesh_lookup)?;
431    if let Some(binding) = probe_bindings.iter().find(|b| b.id == hit.id) {
432        probe_scalar(&mut hit, binding);
433    }
434    Some(hit)
435}
436
437/// Like [`pick_scene_accelerated_cpu`](crate::geometry::bvh::pick_scene_accelerated_cpu) but also
438/// computes the scalar value at the hit point.
439///
440/// See [`pick_scene_with_probe_cpu`] for details on probe bindings.
441pub fn pick_scene_accelerated_with_probe_cpu(
442    ray_origin: glam::Vec3,
443    ray_dir: glam::Vec3,
444    accelerator: &mut crate::geometry::bvh::PickAccelerator,
445    mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
446    probe_bindings: &[ProbeBinding<'_>],
447) -> Option<PickHit> {
448    let mut hit = accelerator.pick(ray_origin, ray_dir, mesh_lookup)?;
449    if let Some(binding) = probe_bindings.iter().find(|b| b.id == hit.id) {
450        probe_scalar(&mut hit, binding);
451    }
452    Some(hit)
453}
454
455// ---------------------------------------------------------------------------
456// RectPickResult : rubber-band / sub-object selection
457// ---------------------------------------------------------------------------
458
459/// Result of a rectangular (rubber-band) pick.
460///
461/// Maps each hit object's identifier to the typed sub-object references that
462/// fall inside the selection rectangle:
463/// - For mesh objects: [`SubObjectRef::Face`] entries whose centroid projects inside the rect.
464/// - For point clouds: [`SubObjectRef::Point`] entries whose position projects inside the rect.
465#[derive(Clone, Debug, Default)]
466pub struct RectPickResult {
467    /// Per-object typed sub-object references.
468    ///
469    /// Key = object identifier: [`crate::renderer::PickId`]`.0` (the scene node id)
470    /// for mesh scene items, [`crate::renderer::PointCloudItem::id`] for point clouds.
471    /// Value = [`SubObjectRef`]s inside the rect : `Face` for mesh triangles,
472    /// `Point` for point cloud points.
473    pub hits: std::collections::HashMap<u64, Vec<SubObjectRef>>,
474}
475
476impl RectPickResult {
477    /// Returns `true` when no objects were hit.
478    pub fn is_empty(&self) -> bool {
479        self.hits.is_empty()
480    }
481
482    /// Total number of sub-object indices across all hit objects.
483    pub fn total_count(&self) -> usize {
484        self.hits.values().map(|v| v.len()).sum()
485    }
486}
487
488/// Sub-object (triangle / point) selection inside a screen-space rectangle.
489///
490/// Projects triangle centroids (for mesh scene items) and point positions (for
491/// point clouds) through `view_proj`, then tests NDC containment against the
492/// rectangle defined by `rect_min`..`rect_max` (viewport-local pixels, top-left
493/// origin).
494///
495/// This is a **pure CPU** operation : no GPU readback is required.
496///
497/// # Arguments
498/// * `rect_min` : top-left corner of the selection rect in viewport pixels
499/// * `rect_max` : bottom-right corner of the selection rect in viewport pixels
500/// * `scene_items` : visible scene render items for this frame
501/// * `mesh_lookup` : CPU-side mesh data keyed by `SceneRenderItem::mesh_index`
502/// * `point_clouds` : point cloud items for this frame
503/// * `view_proj` : combined view × projection matrix
504/// * `viewport_size` : viewport width × height in pixels
505pub fn pick_rect(
506    rect_min: glam::Vec2,
507    rect_max: glam::Vec2,
508    scene_items: &[crate::renderer::SceneRenderItem],
509    mesh_lookup: &std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)>,
510    point_clouds: &[crate::renderer::PointCloudItem],
511    view_proj: glam::Mat4,
512    viewport_size: glam::Vec2,
513) -> RectPickResult {
514    // Convert screen rect to NDC rect.
515    // Screen: x right, y down. NDC: x right, y up.
516    let ndc_min = glam::Vec2::new(
517        rect_min.x / viewport_size.x * 2.0 - 1.0,
518        1.0 - rect_max.y / viewport_size.y * 2.0, // rect_max.y is the bottom in screen space
519    );
520    let ndc_max = glam::Vec2::new(
521        rect_max.x / viewport_size.x * 2.0 - 1.0,
522        1.0 - rect_min.y / viewport_size.y * 2.0, // rect_min.y is the top in screen space
523    );
524
525    let mut result = RectPickResult::default();
526
527    // --- Mesh scene items ---
528    for item in scene_items {
529        if item.appearance.hidden {
530            continue;
531        }
532        let Some((positions, indices)) = mesh_lookup.get(&item.mesh_id.index()) else {
533            continue;
534        };
535
536        let model = glam::Mat4::from_cols_array_2d(&item.model);
537        let mvp = view_proj * model;
538
539        let mut tri_hits: Vec<SubObjectRef> = Vec::new();
540
541        for (tri_idx, chunk) in indices.chunks(3).enumerate() {
542            if chunk.len() < 3 {
543                continue;
544            }
545            let i0 = chunk[0] as usize;
546            let i1 = chunk[1] as usize;
547            let i2 = chunk[2] as usize;
548
549            if i0 >= positions.len() || i1 >= positions.len() || i2 >= positions.len() {
550                continue;
551            }
552
553            let p0 = glam::Vec3::from(positions[i0]);
554            let p1 = glam::Vec3::from(positions[i1]);
555            let p2 = glam::Vec3::from(positions[i2]);
556            let centroid = (p0 + p1 + p2) / 3.0;
557
558            let clip = mvp * centroid.extend(1.0);
559            if clip.w <= 0.0 {
560                // Behind the camera : skip.
561                continue;
562            }
563            let ndc = glam::Vec2::new(clip.x / clip.w, clip.y / clip.w);
564
565            if ndc.x >= ndc_min.x && ndc.x <= ndc_max.x && ndc.y >= ndc_min.y && ndc.y <= ndc_max.y
566            {
567                tri_hits.push(SubObjectRef::Face(tri_idx as u32));
568            }
569        }
570
571        if !tri_hits.is_empty() {
572            result.hits.insert(item.pick_id.0, tri_hits);
573        }
574    }
575
576    // --- Point cloud items ---
577    for pc in point_clouds {
578        if pc.id == 0 {
579            // Not pickable.
580            continue;
581        }
582
583        let model = glam::Mat4::from_cols_array_2d(&pc.model);
584        let mvp = view_proj * model;
585
586        let mut pt_hits: Vec<SubObjectRef> = Vec::new();
587
588        for (pt_idx, pos) in pc.positions.iter().enumerate() {
589            let p = glam::Vec3::from(*pos);
590            let clip = mvp * p.extend(1.0);
591            if clip.w <= 0.0 {
592                continue;
593            }
594            let ndc = glam::Vec2::new(clip.x / clip.w, clip.y / clip.w);
595
596            if ndc.x >= ndc_min.x && ndc.x <= ndc_max.x && ndc.y >= ndc_min.y && ndc.y <= ndc_max.y
597            {
598                pt_hits.push(SubObjectRef::Point(pt_idx as u32));
599            }
600        }
601
602        if !pt_hits.is_empty() {
603            result.hits.insert(pc.id, pt_hits);
604        }
605    }
606
607    result
608}
609
610/// Select all visible objects whose world-space position projects inside a
611/// screen-space rectangle.
612///
613/// Projects each object's position via `view_proj` to screen coordinates,
614/// then tests containment in the rectangle defined by `rect_min`..`rect_max`
615/// (in viewport-local pixels, top-left origin).
616///
617/// Returns the IDs of all objects inside the rectangle.
618pub fn box_select(
619    rect_min: glam::Vec2,
620    rect_max: glam::Vec2,
621    objects: &[&dyn ViewportObject],
622    view_proj: glam::Mat4,
623    viewport_size: glam::Vec2,
624) -> Vec<u64> {
625    let mut hits = Vec::new();
626    for obj in objects {
627        if !obj.is_visible() {
628            continue;
629        }
630        let pos = obj.position();
631        let clip = view_proj * pos.extend(1.0);
632        // Behind the camera : skip.
633        if clip.w <= 0.0 {
634            continue;
635        }
636        let ndc = glam::Vec3::new(clip.x / clip.w, clip.y / clip.w, clip.z / clip.w);
637        let screen = glam::Vec2::new(
638            (ndc.x + 1.0) * 0.5 * viewport_size.x,
639            (1.0 - ndc.y) * 0.5 * viewport_size.y,
640        );
641        if screen.x >= rect_min.x
642            && screen.x <= rect_max.x
643            && screen.y >= rect_min.y
644            && screen.y <= rect_max.y
645        {
646            hits.push(obj.id());
647        }
648    }
649    hits
650}
651
652// ---------------------------------------------------------------------------
653// Volume ray-cast picking
654// ---------------------------------------------------------------------------
655
656/// Slab-method AABB intersection in an arbitrary coordinate space.
657///
658/// Returns `(t_entry, t_exit, entry_axis, entry_sign)` or `None` on miss.
659/// - `entry_axis` : 0/1/2 for x/y/z
660/// - `entry_sign` : ±1.0 — sign of the outward face normal on the entry face
661///   (points back toward the ray origin)
662fn ray_aabb_volume(
663    origin: glam::Vec3,
664    dir: glam::Vec3,
665    bbox_min: glam::Vec3,
666    bbox_max: glam::Vec3,
667) -> Option<(f32, f32, usize, f32)> {
668    let mut t_min = f32::NEG_INFINITY;
669    let mut t_max = f32::INFINITY;
670    let mut entry_axis = 0usize;
671    let mut entry_sign = -1.0f32;
672
673    let dirs = [dir.x, dir.y, dir.z];
674    let origins = [origin.x, origin.y, origin.z];
675    let mins = [bbox_min.x, bbox_min.y, bbox_min.z];
676    let maxs = [bbox_max.x, bbox_max.y, bbox_max.z];
677
678    for i in 0..3 {
679        let d = dirs[i];
680        let o = origins[i];
681        if d.abs() < 1e-12 {
682            // Ray parallel to slab: origin must be inside.
683            if o < mins[i] || o > maxs[i] {
684                return None;
685            }
686        } else {
687            let t1 = (mins[i] - o) / d;
688            let t2 = (maxs[i] - o) / d;
689            let (t_near, t_far) = if t1 <= t2 { (t1, t2) } else { (t2, t1) };
690            if t_near > t_min {
691                t_min = t_near;
692                entry_axis = i;
693                // d > 0 -> entered through min face -> outward normal = -e_i -> sign = -1.
694                // d < 0 -> entered through max face -> outward normal = +e_i -> sign = +1.
695                entry_sign = if d > 0.0 { -1.0 } else { 1.0 };
696            }
697            if t_far < t_max {
698                t_max = t_far;
699            }
700        }
701    }
702
703    if t_min > t_max || t_max < 0.0 {
704        return None;
705    }
706    Some((t_min, t_max, entry_axis, entry_sign))
707}
708
709/// Ray-cast a single volume using Amanatides-Woo DDA traversal.
710///
711/// Walks voxels in exact ray order — no steps are skipped — and returns a
712/// [`PickHit`] for the first voxel whose raw scalar value falls within
713/// `[item.threshold_min, item.threshold_max]`.
714///
715/// # Arguments
716/// * `ray_origin` : world-space ray origin
717/// * `ray_dir` : world-space ray direction (normalized)
718/// * `id` : caller-assigned object identifier, copied into [`PickHit::id`]
719/// * `item` : volume render parameters (bounding box, transform, thresholds)
720/// * `volume` : CPU-side scalar field — same data passed to
721///   [`upload_volume`](crate::resources::ViewportGpuResources::upload_volume)
722///
723/// # Returns
724/// `Some(PickHit)` on a hit:
725/// - `sub_object` : [`SubObjectRef::Voxel`] carrying the flat grid index
726///   `ix + iy*nx + iz*nx*ny`
727/// - `world_pos` : ray entry point into the hit voxel
728/// - `normal` : world-space outward face normal of the voxel face entered
729/// - `scalar_value` : raw scalar at the hit voxel
730///
731/// Returns `None` if the ray misses the bounding box or every voxel in
732/// the path is outside the threshold range (or NaN).
733pub fn pick_volume_cpu(
734    ray_origin: glam::Vec3,
735    ray_dir: glam::Vec3,
736    id: u64,
737    item: &crate::renderer::VolumeItem,
738    volume: &VolumeData,
739) -> Option<PickHit> {
740    let [nx, ny, nz] = volume.dims;
741    if nx == 0 || ny == 0 || nz == 0 || volume.data.is_empty() {
742        return None;
743    }
744
745    // Transform ray to model-local space (handles rotation, scale, translation).
746    let model = glam::Mat4::from_cols_array_2d(&item.model);
747    let inv_model = model.inverse();
748    let local_origin = inv_model.transform_point3(ray_origin);
749    let local_dir = inv_model.transform_vector3(ray_dir);
750
751    let bbox_min = glam::Vec3::from(item.bbox_min);
752    let bbox_max = glam::Vec3::from(item.bbox_max);
753
754    let (t_entry, t_exit, entry_axis, entry_sign) =
755        ray_aabb_volume(local_origin, local_dir, bbox_min, bbox_max)?;
756
757    // Advance to AABB surface if the ray starts outside.
758    let t_start = t_entry.max(0.0);
759    if t_start >= t_exit {
760        return None;
761    }
762
763    // Cell dimensions in local space.
764    let extent = bbox_max - bbox_min;
765    let cell = extent / glam::Vec3::new(nx as f32, ny as f32, nz as f32);
766
767    // Entry point in local space.
768    let p_entry = local_origin + t_start * local_dir;
769
770    // Starting grid cell. Nudge the fractional position slightly inside to
771    // avoid landing exactly on a boundary and mis-classifying the first cell.
772    let eps = 1e-4_f32;
773    let frac =
774        ((p_entry - bbox_min) / extent).clamp(glam::Vec3::splat(eps), glam::Vec3::splat(1.0 - eps));
775    let mut ix = (frac.x * nx as f32).floor() as i32;
776    let mut iy = (frac.y * ny as f32).floor() as i32;
777    let mut iz = (frac.z * nz as f32).floor() as i32;
778    ix = ix.clamp(0, nx as i32 - 1);
779    iy = iy.clamp(0, ny as i32 - 1);
780    iz = iz.clamp(0, nz as i32 - 1);
781
782    // DDA step direction per axis (+1 or -1).
783    let step_x: i32 = if local_dir.x >= 0.0 { 1 } else { -1 };
784    let step_y: i32 = if local_dir.y >= 0.0 { 1 } else { -1 };
785    let step_z: i32 = if local_dir.z >= 0.0 { 1 } else { -1 };
786
787    // t increment to traverse one cell in each axis.
788    let td_x = if local_dir.x.abs() > 1e-12 {
789        cell.x / local_dir.x.abs()
790    } else {
791        f32::INFINITY
792    };
793    let td_y = if local_dir.y.abs() > 1e-12 {
794        cell.y / local_dir.y.abs()
795    } else {
796        f32::INFINITY
797    };
798    let td_z = if local_dir.z.abs() > 1e-12 {
799        cell.z / local_dir.z.abs()
800    } else {
801        f32::INFINITY
802    };
803
804    // t to the next axis-aligned boundary ahead of p_entry in each axis.
805    let next_bx = bbox_min.x + (if step_x > 0 { ix + 1 } else { ix }) as f32 * cell.x;
806    let next_by = bbox_min.y + (if step_y > 0 { iy + 1 } else { iy }) as f32 * cell.y;
807    let next_bz = bbox_min.z + (if step_z > 0 { iz + 1 } else { iz }) as f32 * cell.z;
808
809    let mut tmax_x = if local_dir.x.abs() > 1e-12 {
810        t_start + (next_bx - p_entry.x) / local_dir.x
811    } else {
812        f32::INFINITY
813    };
814    let mut tmax_y = if local_dir.y.abs() > 1e-12 {
815        t_start + (next_by - p_entry.y) / local_dir.y
816    } else {
817        f32::INFINITY
818    };
819    let mut tmax_z = if local_dir.z.abs() > 1e-12 {
820        t_start + (next_bz - p_entry.z) / local_dir.z
821    } else {
822        f32::INFINITY
823    };
824
825    // Outward face normal in local space for the face the ray is currently entering.
826    let mut entry_normal_local = glam::Vec3::ZERO;
827    match entry_axis {
828        0 => entry_normal_local.x = entry_sign,
829        1 => entry_normal_local.y = entry_sign,
830        _ => entry_normal_local.z = entry_sign,
831    }
832
833    // t at which we entered the current voxel (for computing world_pos on hit).
834    let mut t_voxel_entry = t_start;
835
836    loop {
837        // Safety bounds check: exit if DDA has walked outside the grid.
838        if ix < 0 || ix >= nx as i32 || iy < 0 || iy >= ny as i32 || iz < 0 || iz >= nz as i32 {
839            break;
840        }
841
842        let flat = ix as u32 + iy as u32 * nx + iz as u32 * nx * ny;
843        let scalar = volume.data[flat as usize];
844
845        // Skip NaN and out-of-threshold voxels (mirrors the shader behaviour).
846        if !scalar.is_nan() && scalar >= item.threshold_min && scalar <= item.threshold_max {
847            let local_hit = local_origin + t_voxel_entry * local_dir;
848            let world_pos = model.transform_point3(local_hit);
849            // Normals transform by the inverse-transpose to handle non-uniform scale.
850            let world_normal = inv_model
851                .transpose()
852                .transform_vector3(entry_normal_local)
853                .normalize();
854
855            #[allow(deprecated)]
856            return Some(PickHit {
857                id,
858                sub_object: Some(SubObjectRef::Voxel(flat)),
859                world_pos,
860                normal: world_normal,
861                triangle_index: u32::MAX,
862                point_index: None,
863                scalar_value: Some(scalar),
864            });
865        }
866
867        // Advance to the next voxel: step along the axis with the smallest tMax.
868        if tmax_x <= tmax_y && tmax_x <= tmax_z {
869            if tmax_x > t_exit {
870                break;
871            }
872            t_voxel_entry = tmax_x;
873            tmax_x += td_x;
874            ix += step_x;
875            entry_normal_local = glam::Vec3::new(-(step_x as f32), 0.0, 0.0);
876        } else if tmax_y <= tmax_z {
877            if tmax_y > t_exit {
878                break;
879            }
880            t_voxel_entry = tmax_y;
881            tmax_y += td_y;
882            iy += step_y;
883            entry_normal_local = glam::Vec3::new(0.0, -(step_y as f32), 0.0);
884        } else {
885            if tmax_z > t_exit {
886                break;
887            }
888            t_voxel_entry = tmax_z;
889            tmax_z += td_z;
890            iz += step_z;
891            entry_normal_local = glam::Vec3::new(0.0, 0.0, -(step_z as f32));
892        }
893    }
894
895    None
896}
897
898/// Compute the world-space axis-aligned bounding box of a single voxel.
899///
900/// Given the flat voxel index from [`SubObjectRef::Voxel`], returns
901/// `(world_min, world_max)` suitable for positioning a highlight wireframe
902/// around the selected voxel.
903///
904/// When `item.model` contains rotation or non-uniform scale the returned AABB
905/// is the world-space envelope of the (non-axis-aligned) voxel — computed by
906/// transforming all 8 corners.
907///
908/// # Panics
909///
910/// Panics if `flat_index` is out of bounds for `volume.dims`.
911pub fn voxel_world_aabb(
912    flat_index: u32,
913    volume: &VolumeData,
914    item: &crate::renderer::VolumeItem,
915) -> (glam::Vec3, glam::Vec3) {
916    let [nx, ny, nz] = volume.dims;
917    let ix = flat_index % nx;
918    let iy = (flat_index / nx) % ny;
919    let iz = flat_index / (nx * ny);
920    assert!(
921        ix < nx && iy < ny && iz < nz,
922        "flat_index {} out of bounds for dims {:?}",
923        flat_index,
924        volume.dims
925    );
926
927    let bbox_min = glam::Vec3::from(item.bbox_min);
928    let bbox_max = glam::Vec3::from(item.bbox_max);
929    let cell = (bbox_max - bbox_min) / glam::Vec3::new(nx as f32, ny as f32, nz as f32);
930
931    let local_lo =
932        bbox_min + glam::Vec3::new(ix as f32 * cell.x, iy as f32 * cell.y, iz as f32 * cell.z);
933    let local_hi = local_lo + cell;
934
935    let model = glam::Mat4::from_cols_array_2d(&item.model);
936    let corners = [
937        glam::Vec3::new(local_lo.x, local_lo.y, local_lo.z),
938        glam::Vec3::new(local_hi.x, local_lo.y, local_lo.z),
939        glam::Vec3::new(local_lo.x, local_hi.y, local_lo.z),
940        glam::Vec3::new(local_hi.x, local_hi.y, local_lo.z),
941        glam::Vec3::new(local_lo.x, local_lo.y, local_hi.z),
942        glam::Vec3::new(local_hi.x, local_lo.y, local_hi.z),
943        glam::Vec3::new(local_lo.x, local_hi.y, local_hi.z),
944        glam::Vec3::new(local_hi.x, local_hi.y, local_hi.z),
945    ];
946
947    let world_min = corners
948        .iter()
949        .map(|&c| model.transform_point3(c))
950        .fold(glam::Vec3::splat(f32::INFINITY), |acc, c| acc.min(c));
951    let world_max = corners
952        .iter()
953        .map(|&c| model.transform_point3(c))
954        .fold(glam::Vec3::splat(f32::NEG_INFINITY), |acc, c| acc.max(c));
955
956    (world_min, world_max)
957}
958
959/// Pick the closest point in a [`crate::renderer::PointCloudItem`] to a screen-space click.
960///
961/// Projects every point through `view_proj` and returns the closest one whose
962/// screen-space distance to `click_pos` is within `radius_px` pixels.  Returns
963/// `None` when no point is within that radius.
964///
965/// # Arguments
966/// * `click_pos`     – screen-space click position in viewport pixels (top-left origin)
967/// * `id`            – object identifier to embed in the returned [`PickHit`]
968/// * `item`          – the point cloud item to search
969/// * `view_proj`     – combined view × projection matrix
970/// * `viewport_size` – viewport width × height in pixels
971/// * `radius_px`     – maximum screen-space distance in pixels to accept as a hit
972pub fn pick_point_cloud_cpu(
973    click_pos: glam::Vec2,
974    id: u64,
975    item: &crate::renderer::PointCloudItem,
976    view_proj: glam::Mat4,
977    viewport_size: glam::Vec2,
978    radius_px: f32,
979) -> Option<PickHit> {
980    if id == 0 || item.positions.is_empty() {
981        return None;
982    }
983
984    let model = glam::Mat4::from_cols_array_2d(&item.model);
985    let mvp = view_proj * model;
986
987    let mut best_dist_sq = radius_px * radius_px;
988    let mut best_idx: Option<u32> = None;
989    let mut best_world = glam::Vec3::ZERO;
990
991    for (pt_idx, pos) in item.positions.iter().enumerate() {
992        let local = glam::Vec3::from(*pos);
993        let clip = mvp * local.extend(1.0);
994        if clip.w <= 0.0 {
995            continue;
996        }
997        let ndc_x = clip.x / clip.w;
998        let ndc_y = clip.y / clip.w;
999        let sx = (ndc_x + 1.0) * 0.5 * viewport_size.x;
1000        let sy = (1.0 - ndc_y) * 0.5 * viewport_size.y;
1001        let dx = sx - click_pos.x;
1002        let dy = sy - click_pos.y;
1003        let dist_sq = dx * dx + dy * dy;
1004        if dist_sq < best_dist_sq {
1005            best_dist_sq = dist_sq;
1006            best_idx = Some(pt_idx as u32);
1007            best_world = model.transform_point3(local);
1008        }
1009    }
1010
1011    let pt_idx = best_idx?;
1012    #[allow(deprecated)]
1013    Some(PickHit {
1014        id,
1015        sub_object: Some(SubObjectRef::Point(pt_idx)),
1016        world_pos: best_world,
1017        normal: glam::Vec3::Y,
1018        triangle_index: u32::MAX,
1019        point_index: Some(pt_idx),
1020        scalar_value: None,
1021    })
1022}
1023
1024// ---------------------------------------------------------------------------
1025// nearest_vertex_on_hit
1026// ---------------------------------------------------------------------------
1027
1028/// Find the triangle corner nearest to the ray-hit world position.
1029///
1030/// Takes a hit from [`pick_scene_nodes_cpu`] (which carries a
1031/// [`SubObjectRef::Face`] sub-object) and returns the index of the closest
1032/// triangle corner as a [`SubObjectRef::Vertex`].
1033///
1034/// Returns `None` if `hit.sub_object` is not a `Face`, or if the face index is
1035/// out of range for the provided buffers.
1036///
1037/// # Arguments
1038/// * `hit`       - result from a mesh ray-cast pick
1039/// * `positions` - local-space vertex positions for the hit mesh
1040/// * `indices`   - triangle index buffer (every 3 consecutive entries form one triangle)
1041/// * `model`     - world transform for the hit object
1042pub fn nearest_vertex_on_hit(
1043    hit: &PickHit,
1044    positions: &[[f32; 3]],
1045    indices: &[u32],
1046    model: glam::Mat4,
1047) -> Option<SubObjectRef> {
1048    let face_raw = match hit.sub_object {
1049        Some(SubObjectRef::Face(i)) => i as usize,
1050        _ => return None,
1051    };
1052    let n_tri = indices.len() / 3;
1053    if n_tri == 0 {
1054        return None;
1055    }
1056    // parry3d may return a backface index offset by n_tri; normalise.
1057    let face = if face_raw >= n_tri {
1058        face_raw - n_tri
1059    } else {
1060        face_raw
1061    };
1062    if face * 3 + 2 >= indices.len() {
1063        return None;
1064    }
1065    let vi = [
1066        indices[face * 3] as usize,
1067        indices[face * 3 + 1] as usize,
1068        indices[face * 3 + 2] as usize,
1069    ];
1070    let (best_vi, _) = vi
1071        .iter()
1072        .map(|&i| {
1073            let p = model.transform_point3(glam::Vec3::from(positions[i]));
1074            (i, p.distance(hit.world_pos))
1075        })
1076        .fold(
1077            (vi[0], f32::MAX),
1078            |acc, (i, d)| {
1079                if d < acc.1 { (i, d) } else { acc }
1080            },
1081        );
1082    Some(SubObjectRef::Vertex(best_vi as u32))
1083}
1084
1085// ---------------------------------------------------------------------------
1086// pick_gaussian_splat_cpu
1087// ---------------------------------------------------------------------------
1088
1089/// Screen-space nearest-splat pick for a Gaussian splat object.
1090///
1091/// Projects every splat position through `view_proj` and returns the closest
1092/// one whose screen-space distance to `click_pos` is within `radius_px`
1093/// pixels. Returns `None` when no splat qualifies.
1094///
1095/// The returned hit carries [`SubObjectRef::Point`] with the splat index.
1096///
1097/// # Arguments
1098/// * `click_pos`     - screen-space click in viewport pixels (top-left origin)
1099/// * `id`            - object identifier embedded in the returned [`PickHit`]
1100/// * `positions`     - local-space splat center positions
1101/// * `model`         - world transform for the splat object
1102/// * `view_proj`     - combined view x projection matrix
1103/// * `viewport_size` - viewport width x height in pixels
1104/// * `radius_px`     - maximum screen-space distance in pixels to accept as a hit
1105pub fn pick_gaussian_splat_cpu(
1106    click_pos: glam::Vec2,
1107    id: u64,
1108    positions: &[[f32; 3]],
1109    model: glam::Mat4,
1110    view_proj: glam::Mat4,
1111    viewport_size: glam::Vec2,
1112    radius_px: f32,
1113) -> Option<PickHit> {
1114    if id == 0 || positions.is_empty() {
1115        return None;
1116    }
1117    let mvp = view_proj * model;
1118    let mut best_dist_sq = radius_px * radius_px;
1119    let mut best_idx: Option<u32> = None;
1120    let mut best_world = glam::Vec3::ZERO;
1121
1122    for (i, pos) in positions.iter().enumerate() {
1123        let local = glam::Vec3::from(*pos);
1124        let clip = mvp * local.extend(1.0);
1125        if clip.w <= 0.0 {
1126            continue;
1127        }
1128        let sx = (clip.x / clip.w + 1.0) * 0.5 * viewport_size.x;
1129        let sy = (1.0 - clip.y / clip.w) * 0.5 * viewport_size.y;
1130        let dx = sx - click_pos.x;
1131        let dy = sy - click_pos.y;
1132        let dist_sq = dx * dx + dy * dy;
1133        if dist_sq < best_dist_sq {
1134            best_dist_sq = dist_sq;
1135            best_idx = Some(i as u32);
1136            best_world = model.transform_point3(local);
1137        }
1138    }
1139
1140    let idx = best_idx?;
1141    #[allow(deprecated)]
1142    Some(PickHit {
1143        id,
1144        sub_object: Some(SubObjectRef::Point(idx)),
1145        world_pos: best_world,
1146        normal: glam::Vec3::Y,
1147        triangle_index: u32::MAX,
1148        point_index: Some(idx),
1149        scalar_value: None,
1150    })
1151}
1152
1153// ---------------------------------------------------------------------------
1154// pick_transparent_volume_mesh_cpu
1155// ---------------------------------------------------------------------------
1156
1157/// Double-sided Moller-Trumbore ray-triangle intersection.
1158///
1159/// Returns the ray parameter `t > 0` on hit, `None` otherwise.
1160fn ray_tri_mt_ds(
1161    orig: glam::Vec3,
1162    dir: glam::Vec3,
1163    v0: glam::Vec3,
1164    v1: glam::Vec3,
1165    v2: glam::Vec3,
1166) -> Option<f32> {
1167    let e1 = v1 - v0;
1168    let e2 = v2 - v0;
1169    let h = dir.cross(e2);
1170    let a = e1.dot(h);
1171    if a.abs() < 1e-8 {
1172        return None;
1173    }
1174    let f = 1.0 / a;
1175    let s = orig - v0;
1176    let u = f * s.dot(h);
1177    if !(0.0..=1.0).contains(&u) {
1178        return None;
1179    }
1180    let q = s.cross(e1);
1181    let v = f * dir.dot(q);
1182    if v < 0.0 || u + v > 1.0 {
1183        return None;
1184    }
1185    let t = f * e2.dot(q);
1186    if t > 1e-6 { Some(t) } else { None }
1187}
1188
1189// Triangular face indices for each cell type used in ray picking.
1190// (These cover the outer boundary surface of each cell.)
1191
1192// Tet: 4 triangular faces.
1193const VM_TET_FACES: [[usize; 3]; 4] = [[1, 2, 3], [0, 3, 2], [0, 1, 3], [0, 2, 1]];
1194
1195// Hex: 6 quad faces, each split into 2 triangles (12 total).
1196const VM_HEX_TRIS: [[usize; 3]; 12] = [
1197    [0, 1, 2],
1198    [0, 2, 3], // bottom [0,1,2,3]
1199    [4, 7, 6],
1200    [4, 6, 5], // top    [4,7,6,5]
1201    [0, 4, 5],
1202    [0, 5, 1], // front  [0,4,5,1]
1203    [2, 6, 7],
1204    [2, 7, 3], // back   [2,6,7,3]
1205    [0, 3, 7],
1206    [0, 7, 4], // left   [0,3,7,4]
1207    [1, 5, 6],
1208    [1, 6, 2], // right  [1,5,6,2]
1209];
1210
1211// Pyramid: quad base split into 2 + 4 triangular sides = 6 triangles.
1212const VM_PYRAMID_TRIS: [[usize; 3]; 6] = [
1213    [0, 1, 2],
1214    [0, 2, 3], // base quad [0,1,2,3]
1215    [0, 4, 1],
1216    [1, 4, 2],
1217    [2, 4, 3],
1218    [3, 4, 0], // sides
1219];
1220
1221// Wedge: 2 tri ends + 3 quad sides (each split) = 2 + 6 = 8 triangles.
1222const VM_WEDGE_TRIS: [[usize; 3]; 8] = [
1223    [0, 2, 1],
1224    [3, 4, 5], // tri ends
1225    [0, 1, 4],
1226    [0, 4, 3], // side [0,1,4,3]
1227    [1, 2, 5],
1228    [1, 5, 4], // side [1,2,5,4]
1229    [2, 0, 3],
1230    [2, 3, 5], // side [2,0,3,5]
1231];
1232
1233/// Ray-cast pick against a transparent volume mesh.
1234///
1235/// Tests the ray against each cell in `data` using the cell's outer boundary
1236/// triangles. Returns the frontmost hit cell as a [`SubObjectRef::Cell`].
1237///
1238/// The intersection test runs in local space (inverse-transformed ray), so
1239/// `model` may include translation and uniform scale without loss of accuracy.
1240///
1241/// # Arguments
1242/// * `ray_origin` - world-space ray origin
1243/// * `ray_dir`    - world-space ray direction (need not be normalized)
1244/// * `id`         - object identifier embedded in the returned [`PickHit`]
1245/// * `model`      - world transform for the volume mesh
1246/// * `data`       - CPU-side volume mesh
1247pub fn pick_transparent_volume_mesh_cpu(
1248    ray_origin: glam::Vec3,
1249    ray_dir: glam::Vec3,
1250    id: u64,
1251    model: glam::Mat4,
1252    data: &VolumeMeshData,
1253) -> Option<PickHit> {
1254    if id == 0 || data.cells.is_empty() {
1255        return None;
1256    }
1257    let model_inv = model.inverse();
1258    let local_origin = model_inv.transform_point3(ray_origin);
1259    let local_dir = model_inv.transform_vector3(ray_dir);
1260
1261    let mut best_t = f32::MAX;
1262    let mut best_cell: Option<u32> = None;
1263
1264    for (cell_idx, cell) in data.cells.iter().enumerate() {
1265        let p = |i: usize| glam::Vec3::from(data.positions[cell[i] as usize]);
1266        let tris: &[[usize; 3]] = if cell[4] == CELL_SENTINEL {
1267            &VM_TET_FACES
1268        } else if cell[5] == CELL_SENTINEL {
1269            &VM_PYRAMID_TRIS
1270        } else if cell[6] == CELL_SENTINEL {
1271            &VM_WEDGE_TRIS
1272        } else {
1273            &VM_HEX_TRIS
1274        };
1275        for tri in tris {
1276            if let Some(t) = ray_tri_mt_ds(local_origin, local_dir, p(tri[0]), p(tri[1]), p(tri[2]))
1277            {
1278                if t < best_t {
1279                    best_t = t;
1280                    best_cell = Some(cell_idx as u32);
1281                }
1282            }
1283        }
1284    }
1285
1286    let cell_idx = best_cell?;
1287    let local_hit = local_origin + local_dir * best_t;
1288    let world_hit = model.transform_point3(local_hit);
1289    #[allow(deprecated)]
1290    Some(PickHit {
1291        id,
1292        sub_object: Some(SubObjectRef::Cell(cell_idx)),
1293        world_pos: world_hit,
1294        normal: -ray_dir.normalize(),
1295        triangle_index: u32::MAX,
1296        point_index: None,
1297        scalar_value: None,
1298    })
1299}
1300
1301// ---------------------------------------------------------------------------
1302// pick_volume_rect
1303// ---------------------------------------------------------------------------
1304
1305/// Rect-select above-threshold voxels from a volume object.
1306///
1307/// Projects each voxel center through `view_proj` and collects those that fall
1308/// inside the selection rectangle and have a scalar value within
1309/// `[item.threshold_min, item.threshold_max]`.
1310///
1311/// Returns a [`RectPickResult`] with [`SubObjectRef::Voxel`] entries keyed by `id`.
1312///
1313/// # Arguments
1314/// * `rect_min/max`  - selection rectangle corners in viewport pixels (top-left origin)
1315/// * `id`            - object identifier used as the key in the result
1316/// * `item`          - volume render item (provides model, bbox, thresholds)
1317/// * `volume`        - CPU-side volume data (scalar field and grid dimensions)
1318/// * `view_proj`     - combined view x projection matrix
1319/// * `viewport_size` - viewport width x height in pixels
1320pub fn pick_volume_rect(
1321    rect_min: glam::Vec2,
1322    rect_max: glam::Vec2,
1323    id: u64,
1324    item: &crate::renderer::VolumeItem,
1325    volume: &VolumeData,
1326    view_proj: glam::Mat4,
1327    viewport_size: glam::Vec2,
1328) -> RectPickResult {
1329    let mut result = RectPickResult::default();
1330    if id == 0 {
1331        return result;
1332    }
1333    let model = glam::Mat4::from_cols_array_2d(&item.model);
1334    let bbox_min = glam::Vec3::from(item.bbox_min);
1335    let bbox_max = glam::Vec3::from(item.bbox_max);
1336    let [nx, ny, nz] = volume.dims;
1337    let cell = (bbox_max - bbox_min) / glam::Vec3::new(nx as f32, ny as f32, nz as f32);
1338    let mvp = view_proj * model;
1339
1340    let mut hits: Vec<SubObjectRef> = Vec::new();
1341    for iz in 0..nz {
1342        for iy in 0..ny {
1343            for ix in 0..nx {
1344                let flat = ix + iy * nx + iz * nx * ny;
1345                let scalar = volume.data[flat as usize];
1346                if scalar.is_nan() || scalar < item.threshold_min || scalar > item.threshold_max {
1347                    continue;
1348                }
1349                let local_center = bbox_min
1350                    + cell * glam::Vec3::new(ix as f32 + 0.5, iy as f32 + 0.5, iz as f32 + 0.5);
1351                let clip = mvp * local_center.extend(1.0);
1352                if clip.w <= 0.0 {
1353                    continue;
1354                }
1355                let sx = (clip.x / clip.w + 1.0) * 0.5 * viewport_size.x;
1356                let sy = (1.0 - clip.y / clip.w) * 0.5 * viewport_size.y;
1357                if sx >= rect_min.x && sx <= rect_max.x && sy >= rect_min.y && sy <= rect_max.y {
1358                    hits.push(SubObjectRef::Voxel(flat));
1359                }
1360            }
1361        }
1362    }
1363    if !hits.is_empty() {
1364        result.hits.insert(id, hits);
1365    }
1366    result
1367}
1368
1369// ---------------------------------------------------------------------------
1370// pick_transparent_volume_mesh_rect
1371// ---------------------------------------------------------------------------
1372
1373/// Rect-select cells from a transparent volume mesh.
1374///
1375/// Projects each cell centroid through `view_proj` and collects those inside
1376/// the selection rectangle.
1377///
1378/// Returns a [`RectPickResult`] with [`SubObjectRef::Cell`] entries keyed by `id`.
1379///
1380/// # Arguments
1381/// * `rect_min/max`  - selection rectangle in viewport pixels (top-left origin)
1382/// * `id`            - object identifier used as the key in the result
1383/// * `model`         - world transform for the volume mesh
1384/// * `data`          - CPU-side volume mesh
1385/// * `view_proj`     - combined view x projection matrix
1386/// * `viewport_size` - viewport width x height in pixels
1387pub fn pick_transparent_volume_mesh_rect(
1388    rect_min: glam::Vec2,
1389    rect_max: glam::Vec2,
1390    id: u64,
1391    model: glam::Mat4,
1392    data: &VolumeMeshData,
1393    view_proj: glam::Mat4,
1394    viewport_size: glam::Vec2,
1395) -> RectPickResult {
1396    let mut result = RectPickResult::default();
1397    if id == 0 || data.cells.is_empty() {
1398        return result;
1399    }
1400    let mvp = view_proj * model;
1401    let mut hits: Vec<SubObjectRef> = Vec::new();
1402
1403    for (cell_idx, cell) in data.cells.iter().enumerate() {
1404        let nv: usize = if cell[4] == CELL_SENTINEL {
1405            4
1406        } else if cell[5] == CELL_SENTINEL {
1407            5
1408        } else if cell[6] == CELL_SENTINEL {
1409            6
1410        } else {
1411            8
1412        };
1413        let centroid: glam::Vec3 = cell[..nv]
1414            .iter()
1415            .map(|&vi| glam::Vec3::from(data.positions[vi as usize]))
1416            .sum::<glam::Vec3>()
1417            / nv as f32;
1418        let clip = mvp * centroid.extend(1.0);
1419        if clip.w <= 0.0 {
1420            continue;
1421        }
1422        let sx = (clip.x / clip.w + 1.0) * 0.5 * viewport_size.x;
1423        let sy = (1.0 - clip.y / clip.w) * 0.5 * viewport_size.y;
1424        if sx >= rect_min.x && sx <= rect_max.x && sy >= rect_min.y && sy <= rect_max.y {
1425            hits.push(SubObjectRef::Cell(cell_idx as u32));
1426        }
1427    }
1428    if !hits.is_empty() {
1429        result.hits.insert(id, hits);
1430    }
1431    result
1432}
1433
1434// ---------------------------------------------------------------------------
1435// pick_gaussian_splat_rect
1436// ---------------------------------------------------------------------------
1437
1438/// Rect-select splats from a Gaussian splat object.
1439///
1440/// Projects each splat position through `view_proj` and collects those inside
1441/// the selection rectangle.
1442///
1443/// Returns a [`RectPickResult`] with [`SubObjectRef::Point`] entries keyed by `id`.
1444///
1445/// # Arguments
1446/// * `rect_min/max`  - selection rectangle in viewport pixels (top-left origin)
1447/// * `id`            - object identifier used as the key in the result
1448/// * `positions`     - local-space splat center positions
1449/// * `model`         - world transform for the splat object
1450/// * `view_proj`     - combined view x projection matrix
1451/// * `viewport_size` - viewport width x height in pixels
1452pub fn pick_gaussian_splat_rect(
1453    rect_min: glam::Vec2,
1454    rect_max: glam::Vec2,
1455    id: u64,
1456    positions: &[[f32; 3]],
1457    model: glam::Mat4,
1458    view_proj: glam::Mat4,
1459    viewport_size: glam::Vec2,
1460) -> RectPickResult {
1461    let mut result = RectPickResult::default();
1462    if id == 0 || positions.is_empty() {
1463        return result;
1464    }
1465    let mvp = view_proj * model;
1466    let mut hits: Vec<SubObjectRef> = Vec::new();
1467
1468    for (i, pos) in positions.iter().enumerate() {
1469        let local = glam::Vec3::from(*pos);
1470        let clip = mvp * local.extend(1.0);
1471        if clip.w <= 0.0 {
1472            continue;
1473        }
1474        let sx = (clip.x / clip.w + 1.0) * 0.5 * viewport_size.x;
1475        let sy = (1.0 - clip.y / clip.w) * 0.5 * viewport_size.y;
1476        if sx >= rect_min.x && sx <= rect_max.x && sy >= rect_min.y && sy <= rect_max.y {
1477            hits.push(SubObjectRef::Point(i as u32));
1478        }
1479    }
1480    if !hits.is_empty() {
1481        result.hits.insert(id, hits);
1482    }
1483    result
1484}
1485
1486#[cfg(test)]
1487mod tests {
1488    use super::*;
1489    use crate::scene::traits::ViewportObject;
1490    use std::collections::HashMap;
1491
1492    struct TestObject {
1493        id: u64,
1494        mesh_id: u64,
1495        position: glam::Vec3,
1496        visible: bool,
1497    }
1498
1499    impl ViewportObject for TestObject {
1500        fn id(&self) -> u64 {
1501            self.id
1502        }
1503        fn mesh_id(&self) -> Option<u64> {
1504            Some(self.mesh_id)
1505        }
1506        fn model_matrix(&self) -> glam::Mat4 {
1507            glam::Mat4::from_translation(self.position)
1508        }
1509        fn position(&self) -> glam::Vec3 {
1510            self.position
1511        }
1512        fn rotation(&self) -> glam::Quat {
1513            glam::Quat::IDENTITY
1514        }
1515        fn is_visible(&self) -> bool {
1516            self.visible
1517        }
1518        fn colour(&self) -> glam::Vec3 {
1519            glam::Vec3::ONE
1520        }
1521    }
1522
1523    /// Unit cube centered at origin: 8 vertices, 12 triangles.
1524    fn unit_cube_mesh() -> (Vec<[f32; 3]>, Vec<u32>) {
1525        let positions = vec![
1526            [-0.5, -0.5, -0.5],
1527            [0.5, -0.5, -0.5],
1528            [0.5, 0.5, -0.5],
1529            [-0.5, 0.5, -0.5],
1530            [-0.5, -0.5, 0.5],
1531            [0.5, -0.5, 0.5],
1532            [0.5, 0.5, 0.5],
1533            [-0.5, 0.5, 0.5],
1534        ];
1535        let indices = vec![
1536            0, 1, 2, 2, 3, 0, // front
1537            4, 6, 5, 6, 4, 7, // back
1538            0, 3, 7, 7, 4, 0, // left
1539            1, 5, 6, 6, 2, 1, // right
1540            3, 2, 6, 6, 7, 3, // top
1541            0, 4, 5, 5, 1, 0, // bottom
1542        ];
1543        (positions, indices)
1544    }
1545
1546    #[test]
1547    fn test_screen_to_ray_center() {
1548        // Identity view-proj: screen center should produce a ray along -Z.
1549        let vp_inv = glam::Mat4::IDENTITY;
1550        let (origin, dir) = screen_to_ray(
1551            glam::Vec2::new(400.0, 300.0),
1552            glam::Vec2::new(800.0, 600.0),
1553            vp_inv,
1554        );
1555        // NDC (0,0) -> origin at (0,0,0), direction toward (0,0,1).
1556        assert!(origin.x.abs() < 1e-3, "origin.x={}", origin.x);
1557        assert!(origin.y.abs() < 1e-3, "origin.y={}", origin.y);
1558        assert!(dir.z.abs() > 0.9, "dir should be along Z, got {dir:?}");
1559    }
1560
1561    #[test]
1562    fn test_pick_scene_hit() {
1563        let (positions, indices) = unit_cube_mesh();
1564        let mut mesh_lookup = HashMap::new();
1565        mesh_lookup.insert(1u64, (positions, indices));
1566
1567        let obj = TestObject {
1568            id: 42,
1569            mesh_id: 1,
1570            position: glam::Vec3::ZERO,
1571            visible: true,
1572        };
1573        let objects: Vec<&dyn ViewportObject> = vec![&obj];
1574
1575        // Ray from +Z toward origin should hit the cube.
1576        let result = pick_scene_cpu(
1577            glam::Vec3::new(0.0, 0.0, 5.0),
1578            glam::Vec3::new(0.0, 0.0, -1.0),
1579            &objects,
1580            &mesh_lookup,
1581        );
1582        assert!(result.is_some(), "expected a hit");
1583        let hit = result.unwrap();
1584        assert_eq!(hit.id, 42);
1585        // Front face of unit cube at origin is at z=0.5; ray from z=5 hits at toi=4.5.
1586        assert!(
1587            (hit.world_pos.z - 0.5).abs() < 0.01,
1588            "world_pos.z={}",
1589            hit.world_pos.z
1590        );
1591        // Normal should point roughly toward +Z (toward camera).
1592        assert!(hit.normal.z > 0.9, "normal={:?}", hit.normal);
1593    }
1594
1595    #[test]
1596    fn test_pick_scene_miss() {
1597        let (positions, indices) = unit_cube_mesh();
1598        let mut mesh_lookup = HashMap::new();
1599        mesh_lookup.insert(1u64, (positions, indices));
1600
1601        let obj = TestObject {
1602            id: 42,
1603            mesh_id: 1,
1604            position: glam::Vec3::ZERO,
1605            visible: true,
1606        };
1607        let objects: Vec<&dyn ViewportObject> = vec![&obj];
1608
1609        // Ray far from geometry should miss.
1610        let result = pick_scene_cpu(
1611            glam::Vec3::new(100.0, 100.0, 5.0),
1612            glam::Vec3::new(0.0, 0.0, -1.0),
1613            &objects,
1614            &mesh_lookup,
1615        );
1616        assert!(result.is_none());
1617    }
1618
1619    #[test]
1620    fn test_pick_nearest_wins() {
1621        let (positions, indices) = unit_cube_mesh();
1622        let mut mesh_lookup = HashMap::new();
1623        mesh_lookup.insert(1u64, (positions.clone(), indices.clone()));
1624        mesh_lookup.insert(2u64, (positions, indices));
1625
1626        let near_obj = TestObject {
1627            id: 10,
1628            mesh_id: 1,
1629            position: glam::Vec3::new(0.0, 0.0, 2.0),
1630            visible: true,
1631        };
1632        let far_obj = TestObject {
1633            id: 20,
1634            mesh_id: 2,
1635            position: glam::Vec3::new(0.0, 0.0, -2.0),
1636            visible: true,
1637        };
1638        let objects: Vec<&dyn ViewportObject> = vec![&far_obj, &near_obj];
1639
1640        // Ray from +Z toward -Z should hit the nearer object first.
1641        let result = pick_scene_cpu(
1642            glam::Vec3::new(0.0, 0.0, 10.0),
1643            glam::Vec3::new(0.0, 0.0, -1.0),
1644            &objects,
1645            &mesh_lookup,
1646        );
1647        assert!(result.is_some(), "expected a hit");
1648        assert_eq!(result.unwrap().id, 10);
1649    }
1650
1651    #[test]
1652    fn test_box_select_hits_inside_rect() {
1653        // Place object at origin, use an identity-like view_proj so it projects to screen center.
1654        let view = glam::Mat4::look_at_rh(
1655            glam::Vec3::new(0.0, 0.0, 5.0),
1656            glam::Vec3::ZERO,
1657            glam::Vec3::Y,
1658        );
1659        let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
1660        let vp = proj * view;
1661        let viewport_size = glam::Vec2::new(800.0, 600.0);
1662
1663        let obj = TestObject {
1664            id: 42,
1665            mesh_id: 1,
1666            position: glam::Vec3::ZERO,
1667            visible: true,
1668        };
1669        let objects: Vec<&dyn ViewportObject> = vec![&obj];
1670
1671        // Rectangle around screen center should capture the object.
1672        let result = box_select(
1673            glam::Vec2::new(300.0, 200.0),
1674            glam::Vec2::new(500.0, 400.0),
1675            &objects,
1676            vp,
1677            viewport_size,
1678        );
1679        assert_eq!(result, vec![42]);
1680    }
1681
1682    #[test]
1683    fn test_box_select_skips_hidden() {
1684        let view = glam::Mat4::look_at_rh(
1685            glam::Vec3::new(0.0, 0.0, 5.0),
1686            glam::Vec3::ZERO,
1687            glam::Vec3::Y,
1688        );
1689        let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
1690        let vp = proj * view;
1691        let viewport_size = glam::Vec2::new(800.0, 600.0);
1692
1693        let obj = TestObject {
1694            id: 42,
1695            mesh_id: 1,
1696            position: glam::Vec3::ZERO,
1697            visible: false,
1698        };
1699        let objects: Vec<&dyn ViewportObject> = vec![&obj];
1700
1701        let result = box_select(
1702            glam::Vec2::new(0.0, 0.0),
1703            glam::Vec2::new(800.0, 600.0),
1704            &objects,
1705            vp,
1706            viewport_size,
1707        );
1708        assert!(result.is_empty());
1709    }
1710
1711    #[test]
1712    fn test_pick_scene_nodes_hit() {
1713        let (positions, indices) = unit_cube_mesh();
1714        let mut mesh_lookup = HashMap::new();
1715        mesh_lookup.insert(0u64, (positions, indices));
1716
1717        let mut scene = crate::scene::scene::Scene::new();
1718        scene.add(
1719            Some(crate::resources::mesh_store::MeshId(0)),
1720            glam::Mat4::IDENTITY,
1721            crate::scene::material::Material::default(),
1722        );
1723        scene.update_transforms();
1724
1725        let result = pick_scene_nodes_cpu(
1726            glam::Vec3::new(0.0, 0.0, 5.0),
1727            glam::Vec3::new(0.0, 0.0, -1.0),
1728            &scene,
1729            &mesh_lookup,
1730        );
1731        assert!(result.is_some());
1732    }
1733
1734    #[test]
1735    fn test_pick_scene_nodes_miss() {
1736        let (positions, indices) = unit_cube_mesh();
1737        let mut mesh_lookup = HashMap::new();
1738        mesh_lookup.insert(0u64, (positions, indices));
1739
1740        let mut scene = crate::scene::scene::Scene::new();
1741        scene.add(
1742            Some(crate::resources::mesh_store::MeshId(0)),
1743            glam::Mat4::IDENTITY,
1744            crate::scene::material::Material::default(),
1745        );
1746        scene.update_transforms();
1747
1748        let result = pick_scene_nodes_cpu(
1749            glam::Vec3::new(100.0, 100.0, 5.0),
1750            glam::Vec3::new(0.0, 0.0, -1.0),
1751            &scene,
1752            &mesh_lookup,
1753        );
1754        assert!(result.is_none());
1755    }
1756
1757    #[test]
1758    fn test_probe_vertex_attribute() {
1759        let (positions, indices) = unit_cube_mesh();
1760        let mut mesh_lookup = HashMap::new();
1761        mesh_lookup.insert(1u64, (positions.clone(), indices.clone()));
1762
1763        // Assign a per-vertex scalar: value = vertex index as f32.
1764        let vertex_scalars: Vec<f32> = (0..positions.len()).map(|i| i as f32).collect();
1765
1766        let obj = TestObject {
1767            id: 42,
1768            mesh_id: 1,
1769            position: glam::Vec3::ZERO,
1770            visible: true,
1771        };
1772        let objects: Vec<&dyn ViewportObject> = vec![&obj];
1773
1774        let attr_ref = AttributeRef {
1775            name: "test".to_string(),
1776            kind: AttributeKind::Vertex,
1777        };
1778        let attr_data = AttributeData::Vertex(vertex_scalars);
1779        let bindings = vec![ProbeBinding {
1780            id: 42,
1781            attribute_ref: &attr_ref,
1782            attribute_data: &attr_data,
1783            positions: &positions,
1784            indices: &indices,
1785        }];
1786
1787        let result = pick_scene_with_probe_cpu(
1788            glam::Vec3::new(0.0, 0.0, 5.0),
1789            glam::Vec3::new(0.0, 0.0, -1.0),
1790            &objects,
1791            &mesh_lookup,
1792            &bindings,
1793        );
1794        assert!(result.is_some(), "expected a hit");
1795        let hit = result.unwrap();
1796        assert_eq!(hit.id, 42);
1797        // scalar_value should be populated (interpolated from vertex scalars).
1798        assert!(
1799            hit.scalar_value.is_some(),
1800            "expected scalar_value to be set"
1801        );
1802    }
1803
1804    #[test]
1805    fn test_probe_cell_attribute() {
1806        let (positions, indices) = unit_cube_mesh();
1807        let mut mesh_lookup = HashMap::new();
1808        mesh_lookup.insert(1u64, (positions.clone(), indices.clone()));
1809
1810        // 12 triangles in a unit cube : assign each a scalar.
1811        let num_triangles = indices.len() / 3;
1812        let cell_scalars: Vec<f32> = (0..num_triangles).map(|i| (i as f32) * 10.0).collect();
1813
1814        let obj = TestObject {
1815            id: 42,
1816            mesh_id: 1,
1817            position: glam::Vec3::ZERO,
1818            visible: true,
1819        };
1820        let objects: Vec<&dyn ViewportObject> = vec![&obj];
1821
1822        let attr_ref = AttributeRef {
1823            name: "pressure".to_string(),
1824            kind: AttributeKind::Cell,
1825        };
1826        let attr_data = AttributeData::Cell(cell_scalars.clone());
1827        let bindings = vec![ProbeBinding {
1828            id: 42,
1829            attribute_ref: &attr_ref,
1830            attribute_data: &attr_data,
1831            positions: &positions,
1832            indices: &indices,
1833        }];
1834
1835        let result = pick_scene_with_probe_cpu(
1836            glam::Vec3::new(0.0, 0.0, 5.0),
1837            glam::Vec3::new(0.0, 0.0, -1.0),
1838            &objects,
1839            &mesh_lookup,
1840            &bindings,
1841        );
1842        assert!(result.is_some());
1843        let hit = result.unwrap();
1844        // Cell attribute value should be one of the triangle scalars.
1845        assert!(hit.scalar_value.is_some());
1846        let val = hit.scalar_value.unwrap();
1847        assert!(
1848            cell_scalars.contains(&val),
1849            "scalar_value {val} not in cell_scalars"
1850        );
1851    }
1852
1853    #[test]
1854    fn test_probe_no_binding_leaves_none() {
1855        let (positions, indices) = unit_cube_mesh();
1856        let mut mesh_lookup = HashMap::new();
1857        mesh_lookup.insert(1u64, (positions, indices));
1858
1859        let obj = TestObject {
1860            id: 42,
1861            mesh_id: 1,
1862            position: glam::Vec3::ZERO,
1863            visible: true,
1864        };
1865        let objects: Vec<&dyn ViewportObject> = vec![&obj];
1866
1867        // No probe bindings : scalar_value should remain None.
1868        let result = pick_scene_with_probe_cpu(
1869            glam::Vec3::new(0.0, 0.0, 5.0),
1870            glam::Vec3::new(0.0, 0.0, -1.0),
1871            &objects,
1872            &mesh_lookup,
1873            &[],
1874        );
1875        assert!(result.is_some());
1876        assert!(result.unwrap().scalar_value.is_none());
1877    }
1878
1879    // ---------------------------------------------------------------------------
1880    // pick_rect tests
1881    // ---------------------------------------------------------------------------
1882
1883    /// Build a simple perspective view_proj looking at the origin from +Z.
1884    fn make_view_proj() -> glam::Mat4 {
1885        let view = glam::Mat4::look_at_rh(
1886            glam::Vec3::new(0.0, 0.0, 5.0),
1887            glam::Vec3::ZERO,
1888            glam::Vec3::Y,
1889        );
1890        let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
1891        proj * view
1892    }
1893
1894    #[test]
1895    fn test_pick_rect_mesh_full_screen() {
1896        // A full-screen rect should capture all triangle centroids of the unit cube.
1897        let (positions, indices) = unit_cube_mesh();
1898        let mut mesh_lookup: std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)> =
1899            std::collections::HashMap::new();
1900        mesh_lookup.insert(0, (positions, indices.clone()));
1901
1902        let item = crate::renderer::SceneRenderItem {
1903            mesh_id: crate::resources::mesh_store::MeshId(0),
1904            model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1905            ..Default::default()
1906        };
1907
1908        let view_proj = make_view_proj();
1909        let viewport = glam::Vec2::new(800.0, 600.0);
1910
1911        let result = pick_rect(
1912            glam::Vec2::ZERO,
1913            viewport,
1914            &[item],
1915            &mesh_lookup,
1916            &[],
1917            view_proj,
1918            viewport,
1919        );
1920
1921        // The cube has 12 triangles; front-facing ones project inside the full-screen rect.
1922        assert!(!result.is_empty(), "expected at least one triangle hit");
1923        assert!(result.total_count() > 0);
1924    }
1925
1926    #[test]
1927    fn test_pick_rect_miss() {
1928        // A rect far off-screen should return empty results.
1929        let (positions, indices) = unit_cube_mesh();
1930        let mut mesh_lookup: std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)> =
1931            std::collections::HashMap::new();
1932        mesh_lookup.insert(0, (positions, indices));
1933
1934        let item = crate::renderer::SceneRenderItem {
1935            mesh_id: crate::resources::mesh_store::MeshId(0),
1936            model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1937            ..Default::default()
1938        };
1939
1940        let view_proj = make_view_proj();
1941        let viewport = glam::Vec2::new(800.0, 600.0);
1942
1943        let result = pick_rect(
1944            glam::Vec2::new(700.0, 500.0), // bottom-right corner, cube projects to center
1945            glam::Vec2::new(799.0, 599.0),
1946            &[item],
1947            &mesh_lookup,
1948            &[],
1949            view_proj,
1950            viewport,
1951        );
1952
1953        assert!(result.is_empty(), "expected no hits in off-center rect");
1954    }
1955
1956    #[test]
1957    fn test_pick_rect_point_cloud() {
1958        // Points at the origin should be captured by a full-screen rect.
1959        let view_proj = make_view_proj();
1960        let viewport = glam::Vec2::new(800.0, 600.0);
1961
1962        let pc = crate::renderer::PointCloudItem {
1963            positions: vec![[0.0, 0.0, 0.0], [0.1, 0.1, 0.0]],
1964            model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1965            id: 99,
1966            ..Default::default()
1967        };
1968
1969        let result = pick_rect(
1970            glam::Vec2::ZERO,
1971            viewport,
1972            &[],
1973            &std::collections::HashMap::new(),
1974            &[pc],
1975            view_proj,
1976            viewport,
1977        );
1978
1979        assert!(!result.is_empty(), "expected point cloud hits");
1980        let hits = result.hits.get(&99).expect("expected hits for id 99");
1981        assert_eq!(
1982            hits.len(),
1983            2,
1984            "both points should be inside the full-screen rect"
1985        );
1986        // Verify the hits are typed as Point sub-objects.
1987        assert!(
1988            hits.iter().all(|s| s.is_point()),
1989            "expected SubObjectRef::Point entries"
1990        );
1991        assert_eq!(hits[0], SubObjectRef::Point(0));
1992        assert_eq!(hits[1], SubObjectRef::Point(1));
1993    }
1994
1995    #[test]
1996    fn test_pick_rect_skips_invisible() {
1997        let (positions, indices) = unit_cube_mesh();
1998        let mut mesh_lookup: std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)> =
1999            std::collections::HashMap::new();
2000        mesh_lookup.insert(0, (positions, indices));
2001
2002        let item = crate::renderer::SceneRenderItem {
2003            mesh_id: crate::resources::mesh_store::MeshId(0),
2004            model: glam::Mat4::IDENTITY.to_cols_array_2d(),
2005            appearance: crate::scene::material::AppearanceSettings {
2006                hidden: true,
2007                ..Default::default()
2008            },
2009            ..Default::default()
2010        };
2011
2012        let view_proj = make_view_proj();
2013        let viewport = glam::Vec2::new(800.0, 600.0);
2014
2015        let result = pick_rect(
2016            glam::Vec2::ZERO,
2017            viewport,
2018            &[item],
2019            &mesh_lookup,
2020            &[],
2021            view_proj,
2022            viewport,
2023        );
2024
2025        assert!(result.is_empty(), "invisible items should be skipped");
2026    }
2027
2028    #[test]
2029    fn test_pick_rect_result_type() {
2030        // Verify RectPickResult accessors.
2031        let mut r = RectPickResult::default();
2032        assert!(r.is_empty());
2033        assert_eq!(r.total_count(), 0);
2034
2035        r.hits.insert(
2036            1,
2037            vec![
2038                SubObjectRef::Face(0),
2039                SubObjectRef::Face(1),
2040                SubObjectRef::Face(2),
2041            ],
2042        );
2043        r.hits.insert(2, vec![SubObjectRef::Point(5)]);
2044        assert!(!r.is_empty());
2045        assert_eq!(r.total_count(), 4);
2046    }
2047
2048    #[test]
2049    fn test_barycentric_at_vertices() {
2050        let a = glam::Vec3::new(0.0, 0.0, 0.0);
2051        let b = glam::Vec3::new(1.0, 0.0, 0.0);
2052        let c = glam::Vec3::new(0.0, 1.0, 0.0);
2053
2054        // At vertex a: u=1, v=0, w=0.
2055        let (u, v, w) = super::barycentric(a, a, b, c);
2056        assert!((u - 1.0).abs() < 1e-5, "u={u}");
2057        assert!(v.abs() < 1e-5, "v={v}");
2058        assert!(w.abs() < 1e-5, "w={w}");
2059
2060        // At vertex b: u=0, v=1, w=0.
2061        let (u, v, w) = super::barycentric(b, a, b, c);
2062        assert!(u.abs() < 1e-5, "u={u}");
2063        assert!((v - 1.0).abs() < 1e-5, "v={v}");
2064        assert!(w.abs() < 1e-5, "w={w}");
2065
2066        // At centroid: u=v=w≈1/3.
2067        let centroid = (a + b + c) / 3.0;
2068        let (u, v, w) = super::barycentric(centroid, a, b, c);
2069        assert!((u - 1.0 / 3.0).abs() < 1e-4, "u={u}");
2070        assert!((v - 1.0 / 3.0).abs() < 1e-4, "v={v}");
2071        assert!((w - 1.0 / 3.0).abs() < 1e-4, "w={w}");
2072    }
2073
2074    // ---------------------------------------------------------------------------
2075    // pick_volume_cpu / voxel_world_aabb tests
2076    // ---------------------------------------------------------------------------
2077
2078    fn make_volume_item(
2079        bbox_min: [f32; 3],
2080        bbox_max: [f32; 3],
2081        threshold_min: f32,
2082        threshold_max: f32,
2083    ) -> crate::renderer::VolumeItem {
2084        crate::renderer::VolumeItem {
2085            bbox_min,
2086            bbox_max,
2087            threshold_min,
2088            threshold_max,
2089            ..crate::renderer::VolumeItem::default()
2090        }
2091    }
2092
2093    fn make_volume_data(dims: [u32; 3], fill: f32) -> crate::geometry::marching_cubes::VolumeData {
2094        let n = (dims[0] * dims[1] * dims[2]) as usize;
2095        crate::geometry::marching_cubes::VolumeData {
2096            data: vec![fill; n],
2097            dims,
2098            origin: [0.0; 3],
2099            spacing: [1.0; 3],
2100        }
2101    }
2102
2103    #[test]
2104    fn test_pick_volume_basic_hit() {
2105        // 3x3x3 volume, bbox [0,0,0]->[3,3,3], all scalars 0.8.
2106        // Ray from +y: hits the top-center voxel (ix=1, iy=2, iz=1).
2107        let item = make_volume_item([0.0; 3], [3.0, 3.0, 3.0], 0.5, 1.0);
2108        let volume = make_volume_data([3, 3, 3], 0.8);
2109
2110        let hit = super::pick_volume_cpu(
2111            glam::Vec3::new(1.5, 10.0, 1.5),
2112            glam::Vec3::new(0.0, -1.0, 0.0),
2113            42,
2114            &item,
2115            &volume,
2116        );
2117        assert!(hit.is_some(), "expected a hit");
2118        let hit = hit.unwrap();
2119
2120        assert_eq!(hit.id, 42);
2121        assert_eq!(hit.scalar_value, Some(0.8));
2122
2123        // Decode the flat index.
2124        let flat = hit.sub_object.unwrap().index();
2125        let nx = 3u32;
2126        let ny = 3u32;
2127        let ix = flat % nx;
2128        let iy = (flat / nx) % ny;
2129        let iz = flat / (nx * ny);
2130        assert_eq!((ix, iy, iz), (1, 2, 1), "expected top-centre voxel");
2131
2132        // Entry point should be on the top bbox face (y≈3).
2133        assert!(hit.world_pos.y > 2.9, "world_pos.y={}", hit.world_pos.y);
2134
2135        // Normal should point upward (ray entered through the +y face).
2136        assert!(hit.normal.y > 0.9, "normal={:?}", hit.normal);
2137    }
2138
2139    #[test]
2140    fn test_pick_volume_miss_aabb() {
2141        let item = make_volume_item([0.0; 3], [1.0; 3], 0.0, 1.0);
2142        let volume = make_volume_data([4, 4, 4], 0.5);
2143
2144        // Ray displaced 10 units in x: should miss the unit-cube bbox entirely.
2145        let hit = super::pick_volume_cpu(
2146            glam::Vec3::new(10.0, 5.0, 0.5),
2147            glam::Vec3::new(0.0, -1.0, 0.0),
2148            1,
2149            &item,
2150            &volume,
2151        );
2152        assert!(hit.is_none(), "expected miss");
2153    }
2154
2155    #[test]
2156    fn test_pick_volume_threshold_miss() {
2157        // All scalars (0.3) below threshold_min (0.5) -> no hit.
2158        let item = make_volume_item([0.0; 3], [1.0; 3], 0.5, 1.0);
2159        let volume = make_volume_data([4, 4, 4], 0.3);
2160
2161        let hit = super::pick_volume_cpu(
2162            glam::Vec3::new(0.5, 5.0, 0.5),
2163            glam::Vec3::new(0.0, -1.0, 0.0),
2164            1,
2165            &item,
2166            &volume,
2167        );
2168        assert!(
2169            hit.is_none(),
2170            "expected no hit when all scalars below threshold"
2171        );
2172    }
2173
2174    #[test]
2175    fn test_pick_volume_threshold_skip() {
2176        // 1x3x1 volume along y. Ray from +y enters iy=2 first.
2177        // iy=2: scalar 0.3 (below threshold) -> skipped.
2178        // iy=1: scalar 0.8 (within threshold) -> hit.
2179        // iy=0: not reached.
2180        let item = make_volume_item([0.0; 3], [1.0, 3.0, 1.0], 0.5, 1.0);
2181        let mut volume = make_volume_data([1, 3, 1], 0.0);
2182        // flat index = ix + iy*nx: nx=1, so flat = iy.
2183        volume.data[2] = 0.3;
2184        volume.data[1] = 0.8;
2185        volume.data[0] = 0.8;
2186
2187        let hit = super::pick_volume_cpu(
2188            glam::Vec3::new(0.5, 10.0, 0.5),
2189            glam::Vec3::new(0.0, -1.0, 0.0),
2190            1,
2191            &item,
2192            &volume,
2193        );
2194        assert!(hit.is_some(), "expected a hit");
2195        let hit = hit.unwrap();
2196        let flat = hit.sub_object.unwrap().index();
2197        assert_eq!(flat, 1, "expected iy=1 (flat=1), got flat={flat}");
2198        assert_eq!(hit.scalar_value, Some(0.8));
2199    }
2200
2201    #[test]
2202    fn test_pick_volume_nan_skip() {
2203        // 1x2x1 volume. iy=1 (top) is NaN; iy=0 (bottom) is 0.5.
2204        // Ray from +y skips NaN and hits the valid voxel.
2205        let item = make_volume_item([0.0; 3], [1.0, 2.0, 1.0], 0.0, 1.0);
2206        let mut volume = make_volume_data([1, 2, 1], 0.0);
2207        volume.data[1] = f32::NAN;
2208        volume.data[0] = 0.5;
2209
2210        let hit = super::pick_volume_cpu(
2211            glam::Vec3::new(0.5, 10.0, 0.5),
2212            glam::Vec3::new(0.0, -1.0, 0.0),
2213            1,
2214            &item,
2215            &volume,
2216        );
2217        assert!(hit.is_some(), "expected hit after NaN skip");
2218        let hit = hit.unwrap();
2219        assert_eq!(hit.sub_object.unwrap().index(), 0, "expected iy=0 (flat=0)");
2220        assert_eq!(hit.scalar_value, Some(0.5));
2221    }
2222
2223    #[test]
2224    fn test_pick_volume_dda_no_skip() {
2225        // 10x1x1 volume along x. First 9 voxels are below threshold;
2226        // voxel ix=9 is the only one in range. A ray with a tiny z-component
2227        // (nearly axis-aligned to x) must still reach voxel 9 without skipping.
2228        let item = make_volume_item([0.0; 3], [10.0, 1.0, 1.0], 0.5, 1.0);
2229        let mut volume = make_volume_data([10, 1, 1], 0.0);
2230        volume.data[9] = 0.8;
2231
2232        let dir = glam::Vec3::new(1.0, 0.0, 0.001).normalize();
2233        let hit = super::pick_volume_cpu(glam::Vec3::new(-1.0, 0.5, 0.5), dir, 1, &item, &volume);
2234        assert!(
2235            hit.is_some(),
2236            "DDA must reach the last voxel without skipping"
2237        );
2238        let flat = hit.unwrap().sub_object.unwrap().index();
2239        assert_eq!(flat, 9, "expected ix=9 (flat=9), got flat={flat}");
2240    }
2241
2242    #[test]
2243    fn test_voxel_world_aabb_identity() {
2244        // Identity model, 4x4x4 uniform bbox [0,0,0]->[4,4,4].
2245        let item = make_volume_item([0.0; 3], [4.0, 4.0, 4.0], 0.0, 1.0);
2246        let volume = make_volume_data([4, 4, 4], 0.0);
2247
2248        // Voxel (0,0,0) = flat 0: occupies [0,0,0]->[1,1,1].
2249        let (lo, hi) = super::voxel_world_aabb(0, &volume, &item);
2250        assert!((lo - glam::Vec3::ZERO).length() < 1e-5, "lo={lo:?}");
2251        assert!((hi - glam::Vec3::ONE).length() < 1e-5, "hi={hi:?}");
2252
2253        // Voxel (1,0,0) = flat 1: occupies [1,0,0]->[2,1,1].
2254        let (lo, hi) = super::voxel_world_aabb(1, &volume, &item);
2255        assert!((lo.x - 1.0).abs() < 1e-5 && (hi.x - 2.0).abs() < 1e-5);
2256
2257        // Voxel (1,2,3) = flat 1 + 2*4 + 3*16 = 57: occupies [1,2,3]->[2,3,4].
2258        let (lo, hi) = super::voxel_world_aabb(57, &volume, &item);
2259        assert!(
2260            (lo - glam::Vec3::new(1.0, 2.0, 3.0)).length() < 1e-5,
2261            "lo={lo:?}"
2262        );
2263        assert!(
2264            (hi - glam::Vec3::new(2.0, 3.0, 4.0)).length() < 1e-5,
2265            "hi={hi:?}"
2266        );
2267    }
2268
2269    #[test]
2270    fn test_voxel_world_aabb_round_trip() {
2271        // Pick a voxel, then verify that world_pos from the hit lies inside
2272        // the AABB returned by voxel_world_aabb.
2273        let item = make_volume_item([0.0; 3], [3.0, 3.0, 3.0], 0.5, 1.0);
2274        let volume = make_volume_data([3, 3, 3], 0.8);
2275
2276        let hit = super::pick_volume_cpu(
2277            glam::Vec3::new(1.5, 10.0, 1.5),
2278            glam::Vec3::new(0.0, -1.0, 0.0),
2279            1,
2280            &item,
2281            &volume,
2282        )
2283        .expect("expected a hit for round-trip test");
2284
2285        let flat = hit.sub_object.unwrap().index();
2286        let (lo, hi) = super::voxel_world_aabb(flat, &volume, &item);
2287
2288        let tol = 1e-3;
2289        assert!(
2290            hit.world_pos.x >= lo.x - tol && hit.world_pos.x <= hi.x + tol,
2291            "world_pos.x={} outside [{}, {}]",
2292            hit.world_pos.x,
2293            lo.x,
2294            hi.x
2295        );
2296        assert!(
2297            hit.world_pos.y >= lo.y - tol && hit.world_pos.y <= hi.y + tol,
2298            "world_pos.y={} outside [{}, {}]",
2299            hit.world_pos.y,
2300            lo.y,
2301            hi.y
2302        );
2303        assert!(
2304            hit.world_pos.z >= lo.z - tol && hit.world_pos.z <= hi.z + tol,
2305            "world_pos.z={} outside [{}, {}]",
2306            hit.world_pos.z,
2307            lo.z,
2308            hi.z
2309        );
2310    }
2311}