Skip to main content

viewport_lib/interaction/query/
picking.rs

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