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