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