Skip to main content

viewport_lib/interaction/
picking.rs

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