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