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::interaction::sub_object::SubObjectRef;
6use crate::resources::{AttributeData, AttributeKind, AttributeRef};
7use crate::scene::traits::ViewportObject;
8use parry3d::math::{Pose, Vector};
9use parry3d::query::{Ray, RayCast};
10
11// ---------------------------------------------------------------------------
12// PickHit : rich hit result
13// ---------------------------------------------------------------------------
14
15/// Result of a successful ray-cast pick against a scene object.
16///
17/// Contains the picked object's ID plus geometric metadata about the hit point.
18/// Use this for snapping, measurement, surface painting, and other hit-dependent features.
19#[derive(Clone, Copy, Debug)]
20#[non_exhaustive]
21pub struct PickHit {
22    /// The object/node ID of the hit.
23    pub id: u64,
24    /// Typed sub-object reference : the authoritative source for sub-object identity.
25    ///
26    /// `Some(SubObjectRef::Face(i))` for mesh picks; `Some(SubObjectRef::Point(i))` for
27    /// point cloud picks; `None` when no specific sub-object could be identified.
28    pub sub_object: Option<SubObjectRef>,
29    /// World-space position of the hit point (`ray_origin + ray_dir * toi`).
30    pub world_pos: glam::Vec3,
31    /// Surface normal at the hit point, in world space.
32    pub normal: glam::Vec3,
33    /// Which triangle was hit (from parry3d `FeatureId::Face`).
34    /// `u32::MAX` if the feature was not a face (edge/vertex hit : rare for TriMesh).
35    ///
36    /// **Deprecated** : use [`sub_object`](Self::sub_object) instead.
37    #[deprecated(since = "0.5.0", note = "use `sub_object` instead")]
38    pub triangle_index: u32,
39    /// Index of the hit point within a [`crate::renderer::PointCloudItem`].
40    /// `None` for mesh picks; set when a point cloud item is hit.
41    ///
42    /// **Deprecated** : use [`sub_object`](Self::sub_object) instead.
43    #[deprecated(since = "0.5.0", note = "use `sub_object` instead")]
44    pub point_index: Option<u32>,
45    /// Interpolated scalar attribute value at the hit point.
46    ///
47    /// Populated by the `_with_probe` picking variants when an active attribute
48    /// is provided. For vertex attributes, the value is barycentric-interpolated
49    /// from the three triangle corner values. For cell attributes, the value is
50    /// read directly from the hit triangle index.
51    pub scalar_value: Option<f32>,
52}
53
54// ---------------------------------------------------------------------------
55// GpuPickHit : GPU object-ID pick result
56// ---------------------------------------------------------------------------
57
58/// Result of a GPU object-ID pick pass.
59///
60/// Lighter than [`PickHit`] : carries only the object identifier and the
61/// clip-space depth value at the picked pixel. World position can be
62/// reconstructed from `depth` + the inverse view-projection matrix if needed.
63///
64/// Obtained from [`crate::renderer::ViewportRenderer::pick_scene_gpu`].
65#[derive(Clone, Copy, Debug)]
66#[non_exhaustive]
67pub struct GpuPickHit {
68    /// The `pick_id` of the surface that was hit.
69    ///
70    /// Matches the `SceneRenderItem::pick_id` set by the application.
71    /// Map to a domain object using whatever id-to-object registry the app
72    /// maintains. [`crate::renderer::PickId::NONE`] is never returned
73    /// (non-pickable surfaces are excluded from the pick pass).
74    pub object_id: crate::renderer::PickId,
75    /// Clip-space depth value in `[0, 1]` at the picked pixel.
76    /// `0.0` = near plane, `1.0` = far plane.
77    ///
78    /// Reconstruct world position:
79    /// ```ignore
80    /// let ndc = Vec3::new(ndc_x, ndc_y, hit.depth);
81    /// let world = view_proj_inv.project_point3(ndc);
82    /// ```
83    pub depth: f32,
84}
85
86// ---------------------------------------------------------------------------
87// Public API
88// ---------------------------------------------------------------------------
89
90/// Convert screen position (in viewport-local pixels) to a world-space ray.
91///
92/// Returns (origin, direction) both as glam::Vec3.
93///
94/// # Arguments
95/// * `screen_pos` : mouse position relative to viewport rect top-left
96/// * `viewport_size` : viewport width and height in pixels
97/// * `view_proj_inv` : inverse of (proj * view)
98pub fn screen_to_ray(
99    screen_pos: glam::Vec2,
100    viewport_size: glam::Vec2,
101    view_proj_inv: glam::Mat4,
102) -> (glam::Vec3, glam::Vec3) {
103    let ndc_x = (screen_pos.x / viewport_size.x) * 2.0 - 1.0;
104    let ndc_y = 1.0 - (screen_pos.y / viewport_size.y) * 2.0; // Y flipped (screen Y down, NDC Y up)
105    let near = view_proj_inv.project_point3(glam::Vec3::new(ndc_x, ndc_y, 0.0));
106    let far = view_proj_inv.project_point3(glam::Vec3::new(ndc_x, ndc_y, 1.0));
107    let dir = (far - near).normalize();
108    (near, dir)
109}
110
111/// Cast a ray against all visible viewport objects. Returns a [`PickHit`] for the
112/// nearest hit, or `None` if nothing was hit.
113///
114/// # Arguments
115/// * `ray_origin` : world-space ray origin
116/// * `ray_dir` : world-space ray direction (normalized)
117/// * `objects` : slice of trait objects implementing ViewportObject
118/// * `mesh_lookup` : lookup table: CPU-side positions and indices by mesh_id
119pub fn pick_scene(
120    ray_origin: glam::Vec3,
121    ray_dir: glam::Vec3,
122    objects: &[&dyn ViewportObject],
123    mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
124) -> Option<PickHit> {
125    // parry3d 0.26 uses glam::Vec3 directly (via glamx)
126    let ray = Ray::new(
127        Vector::new(ray_origin.x, ray_origin.y, ray_origin.z),
128        Vector::new(ray_dir.x, ray_dir.y, ray_dir.z),
129    );
130
131    let mut best_hit: Option<(u64, f32, PickHit)> = None;
132
133    for obj in objects {
134        if !obj.is_visible() {
135            continue;
136        }
137        let Some(mesh_id) = obj.mesh_id() else {
138            continue;
139        };
140
141        if let Some((positions, indices)) = mesh_lookup.get(&mesh_id) {
142            // Build parry3d TriMesh for ray cast test.
143            // parry3d::math::Vector == glam::Vec3 in f32 mode.
144            // Pose only carries translation + rotation, so scale must be baked
145            // into the vertices so the hit shape matches the visual geometry.
146            let s = obj.scale();
147            let verts: Vec<Vector> = positions
148                .iter()
149                .map(|p: &[f32; 3]| Vector::new(p[0] * s.x, p[1] * s.y, p[2] * s.z))
150                .collect();
151
152            let tri_indices: Vec<[u32; 3]> = indices
153                .chunks(3)
154                .filter(|c: &&[u32]| c.len() == 3)
155                .map(|c: &[u32]| [c[0], c[1], c[2]])
156                .collect();
157
158            if tri_indices.is_empty() {
159                continue;
160            }
161
162            match parry3d::shape::TriMesh::new(verts, tri_indices) {
163                Ok(trimesh) => {
164                    // Build pose from object position and rotation.
165                    // cast_ray_and_get_normal with a pose automatically transforms
166                    // the normal into world space.
167                    let pose = Pose::from_parts(obj.position(), obj.rotation());
168                    if let Some(intersection) =
169                        trimesh.cast_ray_and_get_normal(&pose, &ray, f32::MAX, true)
170                    {
171                        let toi = intersection.time_of_impact;
172                        if best_hit.is_none() || toi < best_hit.as_ref().unwrap().1 {
173                            let sub_object = SubObjectRef::from_feature_id(intersection.feature);
174                            let world_pos = ray_origin + ray_dir * toi;
175                            // intersection.normal is already in world space (pose transforms it).
176                            let normal = intersection.normal;
177                            let triangle_index = if let Some(SubObjectRef::Face(i)) = sub_object {
178                                i
179                            } else {
180                                u32::MAX
181                            };
182                            #[allow(deprecated)]
183                            let hit = PickHit {
184                                id: obj.id(),
185                                sub_object,
186                                triangle_index,
187                                world_pos,
188                                normal,
189                                point_index: None,
190                                scalar_value: None,
191                            };
192                            best_hit = Some((obj.id(), toi, hit));
193                        }
194                    }
195                }
196                Err(e) => {
197                    tracing::warn!(object_id = obj.id(), error = %e, "TriMesh construction failed for picking");
198                }
199            }
200        }
201    }
202
203    best_hit.map(|(_, _, hit)| hit)
204}
205
206/// Cast a ray against all visible scene nodes. Returns a [`PickHit`] for the nearest hit.
207///
208/// Same ray-cast logic as `pick_scene` but reads from `Scene::nodes()` instead
209/// of `&[&dyn ViewportObject]`.
210pub fn pick_scene_nodes(
211    ray_origin: glam::Vec3,
212    ray_dir: glam::Vec3,
213    scene: &crate::scene::scene::Scene,
214    mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
215) -> Option<PickHit> {
216    let nodes: Vec<&dyn ViewportObject> = scene.nodes().map(|n| n as &dyn ViewportObject).collect();
217    pick_scene(ray_origin, ray_dir, &nodes, mesh_lookup)
218}
219
220// ---------------------------------------------------------------------------
221// Probe-aware picking : scalar value at hit point
222// ---------------------------------------------------------------------------
223
224/// Per-object attribute binding for probe-aware picking.
225///
226/// Maps an object ID to its active scalar attribute data so that
227/// `pick_scene_with_probe` can interpolate the scalar value at the hit point.
228pub struct ProbeBinding<'a> {
229    /// Object/node ID this binding applies to.
230    pub id: u64,
231    /// Which attribute is active (name + vertex/cell kind).
232    pub attribute_ref: &'a AttributeRef,
233    /// The raw attribute data (vertex or cell scalars).
234    pub attribute_data: &'a AttributeData,
235    /// CPU-side mesh positions for barycentric computation.
236    pub positions: &'a [[f32; 3]],
237    /// CPU-side mesh indices (triangle list) for vertex lookup.
238    pub indices: &'a [u32],
239}
240
241/// Compute barycentric coordinates of point `p` on the triangle `(a, b, c)`.
242///
243/// Returns `(u, v, w)` where `p ≈ u*a + v*b + w*c` and `u + v + w ≈ 1`.
244/// Uses the robust area-ratio method (Cramer's rule on the edge vectors).
245fn barycentric(p: glam::Vec3, a: glam::Vec3, b: glam::Vec3, c: glam::Vec3) -> (f32, f32, f32) {
246    let v0 = b - a;
247    let v1 = c - a;
248    let v2 = p - a;
249    let d00 = v0.dot(v0);
250    let d01 = v0.dot(v1);
251    let d11 = v1.dot(v1);
252    let d20 = v2.dot(v0);
253    let d21 = v2.dot(v1);
254    let denom = d00 * d11 - d01 * d01;
255    if denom.abs() < 1e-12 {
256        // Degenerate triangle : return equal weights.
257        return (1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0);
258    }
259    let inv = 1.0 / denom;
260    let v = (d11 * d20 - d01 * d21) * inv;
261    let w = (d00 * d21 - d01 * d20) * inv;
262    let u = 1.0 - v - w;
263    (u, v, w)
264}
265
266/// Given a `PickHit` and matching `ProbeBinding`, compute the scalar value at
267/// the hit point and write it into `hit.scalar_value`.
268fn probe_scalar(hit: &mut PickHit, binding: &ProbeBinding<'_>) {
269    let tri_idx_raw = match hit.sub_object {
270        Some(SubObjectRef::Face(i)) => i,
271        _ => return,
272    };
273
274    let num_triangles = binding.indices.len() / 3;
275    // parry3d may return back-face indices (idx >= num_triangles) for solid
276    // meshes. Map them back to the original triangle.
277    let tri_idx = if (tri_idx_raw as usize) >= num_triangles && num_triangles > 0 {
278        tri_idx_raw as usize - num_triangles
279    } else {
280        tri_idx_raw as usize
281    };
282
283    match binding.attribute_ref.kind {
284        AttributeKind::Cell => {
285            // Cell attribute: one value per triangle : use directly.
286            if let AttributeData::Cell(data) = binding.attribute_data {
287                if let Some(&val) = data.get(tri_idx) {
288                    hit.scalar_value = Some(val);
289                }
290            }
291        }
292        AttributeKind::Face => {
293            // Face attribute: one value per triangle : direct lookup (no averaging).
294            if let AttributeData::Face(data) = binding.attribute_data {
295                if let Some(&val) = data.get(tri_idx) {
296                    hit.scalar_value = Some(val);
297                }
298            }
299        }
300        AttributeKind::FaceColor => {
301            // FaceColor attribute: no scalar value to report.
302        }
303        AttributeKind::Vertex => {
304            // Vertex attribute: barycentric interpolation from triangle corners.
305            if let AttributeData::Vertex(data) = binding.attribute_data {
306                let base = tri_idx * 3;
307                if base + 2 >= binding.indices.len() {
308                    return;
309                }
310                let i0 = binding.indices[base] as usize;
311                let i1 = binding.indices[base + 1] as usize;
312                let i2 = binding.indices[base + 2] as usize;
313
314                if i0 >= data.len() || i1 >= data.len() || i2 >= data.len() {
315                    return;
316                }
317                if i0 >= binding.positions.len()
318                    || i1 >= binding.positions.len()
319                    || i2 >= binding.positions.len()
320                {
321                    return;
322                }
323
324                let a = glam::Vec3::from(binding.positions[i0]);
325                let b = glam::Vec3::from(binding.positions[i1]);
326                let c = glam::Vec3::from(binding.positions[i2]);
327                let (u, v, w) = barycentric(hit.world_pos, a, b, c);
328                hit.scalar_value = Some(data[i0] * u + data[i1] * v + data[i2] * w);
329            }
330        }
331        AttributeKind::Edge => {
332            // Edge attribute: use the corner value at the closest triangle vertex
333            // (edge values are already averaged to vertices at upload time).
334            if let AttributeData::Edge(data) = binding.attribute_data {
335                let base = tri_idx * 3;
336                if base + 2 >= binding.indices.len() || data.is_empty() {
337                    return;
338                }
339                let i0 = binding.indices[base] as usize;
340                let i1 = binding.indices[base + 1] as usize;
341                let i2 = binding.indices[base + 2] as usize;
342                if i0 < data.len() || i1 < data.len() || i2 < data.len() {
343                    // Barycentric interpolation over the per-vertex averaged values.
344                    if i0 < data.len()
345                        && i1 < data.len()
346                        && i2 < data.len()
347                        && i0 < binding.positions.len()
348                        && i1 < binding.positions.len()
349                        && i2 < binding.positions.len()
350                    {
351                        let a = glam::Vec3::from(binding.positions[i0]);
352                        let b = glam::Vec3::from(binding.positions[i1]);
353                        let c = glam::Vec3::from(binding.positions[i2]);
354                        let (u, v, w) = barycentric(hit.world_pos, a, b, c);
355                        hit.scalar_value = Some(data[i0] * u + data[i1] * v + data[i2] * w);
356                    }
357                }
358            }
359        }
360        AttributeKind::Halfedge | AttributeKind::Corner => {
361            // Per-corner attributes: `values[3*t + k]` is the k-th corner of the triangle.
362            // Report the value at the nearest corner (flat shading).
363            let extract = |data: &[f32]| -> Option<f32> {
364                let base = tri_idx * 3;
365                if base + 2 >= data.len() {
366                    return None;
367                }
368                // Return the first corner value as the representative (flat per face).
369                Some(data[base])
370            };
371            match binding.attribute_data {
372                AttributeData::Halfedge(data) | AttributeData::Corner(data) => {
373                    hit.scalar_value = extract(data);
374                }
375                _ => {}
376            }
377        }
378    }
379}
380
381/// Like [`pick_scene`] but also computes the scalar attribute value at the hit
382/// point via barycentric interpolation (vertex attributes) or direct lookup
383/// (cell attributes).
384///
385/// `probe_bindings` maps object IDs to their active attribute data. If the hit
386/// object has no matching binding, `PickHit::scalar_value` remains `None`.
387pub fn pick_scene_with_probe(
388    ray_origin: glam::Vec3,
389    ray_dir: glam::Vec3,
390    objects: &[&dyn ViewportObject],
391    mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
392    probe_bindings: &[ProbeBinding<'_>],
393) -> Option<PickHit> {
394    let mut hit = pick_scene(ray_origin, ray_dir, objects, mesh_lookup)?;
395    if let Some(binding) = probe_bindings.iter().find(|b| b.id == hit.id) {
396        probe_scalar(&mut hit, binding);
397    }
398    Some(hit)
399}
400
401/// Like [`pick_scene_nodes`] but also computes the scalar value at the hit point.
402///
403/// See [`pick_scene_with_probe`] for details on probe bindings.
404pub fn pick_scene_nodes_with_probe(
405    ray_origin: glam::Vec3,
406    ray_dir: glam::Vec3,
407    scene: &crate::scene::scene::Scene,
408    mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
409    probe_bindings: &[ProbeBinding<'_>],
410) -> Option<PickHit> {
411    let mut hit = pick_scene_nodes(ray_origin, ray_dir, scene, mesh_lookup)?;
412    if let Some(binding) = probe_bindings.iter().find(|b| b.id == hit.id) {
413        probe_scalar(&mut hit, binding);
414    }
415    Some(hit)
416}
417
418/// Like [`pick_scene_accelerated`](crate::geometry::bvh::pick_scene_accelerated) but also
419/// computes the scalar value at the hit point.
420///
421/// See [`pick_scene_with_probe`] for details on probe bindings.
422pub fn pick_scene_accelerated_with_probe(
423    ray_origin: glam::Vec3,
424    ray_dir: glam::Vec3,
425    accelerator: &mut crate::geometry::bvh::PickAccelerator,
426    mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
427    probe_bindings: &[ProbeBinding<'_>],
428) -> Option<PickHit> {
429    let mut hit = accelerator.pick(ray_origin, ray_dir, mesh_lookup)?;
430    if let Some(binding) = probe_bindings.iter().find(|b| b.id == hit.id) {
431        probe_scalar(&mut hit, binding);
432    }
433    Some(hit)
434}
435
436// ---------------------------------------------------------------------------
437// RectPickResult : rubber-band / sub-object selection
438// ---------------------------------------------------------------------------
439
440/// Result of a rectangular (rubber-band) pick.
441///
442/// Maps each hit object's identifier to the typed sub-object references that
443/// fall inside the selection rectangle:
444/// - For mesh objects: [`SubObjectRef::Face`] entries whose centroid projects inside the rect.
445/// - For point clouds: [`SubObjectRef::Point`] entries whose position projects inside the rect.
446#[derive(Clone, Debug, Default)]
447pub struct RectPickResult {
448    /// Per-object typed sub-object references.
449    ///
450    /// Key = object identifier (`mesh_index` cast to `u64` for scene items,
451    /// [`crate::renderer::PointCloudItem::id`] for point clouds).
452    /// Value = [`SubObjectRef`]s inside the rect : `Face` for mesh triangles,
453    /// `Point` for point cloud points.
454    pub hits: std::collections::HashMap<u64, Vec<SubObjectRef>>,
455}
456
457impl RectPickResult {
458    /// Returns `true` when no objects were hit.
459    pub fn is_empty(&self) -> bool {
460        self.hits.is_empty()
461    }
462
463    /// Total number of sub-object indices across all hit objects.
464    pub fn total_count(&self) -> usize {
465        self.hits.values().map(|v| v.len()).sum()
466    }
467}
468
469/// Sub-object (triangle / point) selection inside a screen-space rectangle.
470///
471/// Projects triangle centroids (for mesh scene items) and point positions (for
472/// point clouds) through `view_proj`, then tests NDC containment against the
473/// rectangle defined by `rect_min`..`rect_max` (viewport-local pixels, top-left
474/// origin).
475///
476/// This is a **pure CPU** operation : no GPU readback is required.
477///
478/// # Arguments
479/// * `rect_min` : top-left corner of the selection rect in viewport pixels
480/// * `rect_max` : bottom-right corner of the selection rect in viewport pixels
481/// * `scene_items` : visible scene render items for this frame
482/// * `mesh_lookup` : CPU-side mesh data keyed by `SceneRenderItem::mesh_index`
483/// * `point_clouds` : point cloud items for this frame
484/// * `view_proj` : combined view × projection matrix
485/// * `viewport_size` : viewport width × height in pixels
486pub fn pick_rect(
487    rect_min: glam::Vec2,
488    rect_max: glam::Vec2,
489    scene_items: &[crate::renderer::SceneRenderItem],
490    mesh_lookup: &std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)>,
491    point_clouds: &[crate::renderer::PointCloudItem],
492    view_proj: glam::Mat4,
493    viewport_size: glam::Vec2,
494) -> RectPickResult {
495    // Convert screen rect to NDC rect.
496    // Screen: x right, y down. NDC: x right, y up.
497    let ndc_min = glam::Vec2::new(
498        rect_min.x / viewport_size.x * 2.0 - 1.0,
499        1.0 - rect_max.y / viewport_size.y * 2.0, // rect_max.y is the bottom in screen space
500    );
501    let ndc_max = glam::Vec2::new(
502        rect_max.x / viewport_size.x * 2.0 - 1.0,
503        1.0 - rect_min.y / viewport_size.y * 2.0, // rect_min.y is the top in screen space
504    );
505
506    let mut result = RectPickResult::default();
507
508    // --- Mesh scene items ---
509    for item in scene_items {
510        if !item.visible {
511            continue;
512        }
513        let Some((positions, indices)) = mesh_lookup.get(&item.mesh_id.index()) else {
514            continue;
515        };
516
517        let model = glam::Mat4::from_cols_array_2d(&item.model);
518        let mvp = view_proj * model;
519
520        let mut tri_hits: Vec<SubObjectRef> = Vec::new();
521
522        for (tri_idx, chunk) in indices.chunks(3).enumerate() {
523            if chunk.len() < 3 {
524                continue;
525            }
526            let i0 = chunk[0] as usize;
527            let i1 = chunk[1] as usize;
528            let i2 = chunk[2] as usize;
529
530            if i0 >= positions.len() || i1 >= positions.len() || i2 >= positions.len() {
531                continue;
532            }
533
534            let p0 = glam::Vec3::from(positions[i0]);
535            let p1 = glam::Vec3::from(positions[i1]);
536            let p2 = glam::Vec3::from(positions[i2]);
537            let centroid = (p0 + p1 + p2) / 3.0;
538
539            let clip = mvp * centroid.extend(1.0);
540            if clip.w <= 0.0 {
541                // Behind the camera : skip.
542                continue;
543            }
544            let ndc = glam::Vec2::new(clip.x / clip.w, clip.y / clip.w);
545
546            if ndc.x >= ndc_min.x && ndc.x <= ndc_max.x && ndc.y >= ndc_min.y && ndc.y <= ndc_max.y
547            {
548                tri_hits.push(SubObjectRef::Face(tri_idx as u32));
549            }
550        }
551
552        if !tri_hits.is_empty() {
553            result.hits.insert(item.mesh_id.index() as u64, tri_hits);
554        }
555    }
556
557    // --- Point cloud items ---
558    for pc in point_clouds {
559        if pc.id == 0 {
560            // Not pickable.
561            continue;
562        }
563
564        let model = glam::Mat4::from_cols_array_2d(&pc.model);
565        let mvp = view_proj * model;
566
567        let mut pt_hits: Vec<SubObjectRef> = Vec::new();
568
569        for (pt_idx, pos) in pc.positions.iter().enumerate() {
570            let p = glam::Vec3::from(*pos);
571            let clip = mvp * p.extend(1.0);
572            if clip.w <= 0.0 {
573                continue;
574            }
575            let ndc = glam::Vec2::new(clip.x / clip.w, clip.y / clip.w);
576
577            if ndc.x >= ndc_min.x && ndc.x <= ndc_max.x && ndc.y >= ndc_min.y && ndc.y <= ndc_max.y
578            {
579                pt_hits.push(SubObjectRef::Point(pt_idx as u32));
580            }
581        }
582
583        if !pt_hits.is_empty() {
584            result.hits.insert(pc.id, pt_hits);
585        }
586    }
587
588    result
589}
590
591/// Select all visible objects whose world-space position projects inside a
592/// screen-space rectangle.
593///
594/// Projects each object's position via `view_proj` to screen coordinates,
595/// then tests containment in the rectangle defined by `rect_min`..`rect_max`
596/// (in viewport-local pixels, top-left origin).
597///
598/// Returns the IDs of all objects inside the rectangle.
599pub fn box_select(
600    rect_min: glam::Vec2,
601    rect_max: glam::Vec2,
602    objects: &[&dyn ViewportObject],
603    view_proj: glam::Mat4,
604    viewport_size: glam::Vec2,
605) -> Vec<u64> {
606    let mut hits = Vec::new();
607    for obj in objects {
608        if !obj.is_visible() {
609            continue;
610        }
611        let pos = obj.position();
612        let clip = view_proj * pos.extend(1.0);
613        // Behind the camera : skip.
614        if clip.w <= 0.0 {
615            continue;
616        }
617        let ndc = glam::Vec3::new(clip.x / clip.w, clip.y / clip.w, clip.z / clip.w);
618        let screen = glam::Vec2::new(
619            (ndc.x + 1.0) * 0.5 * viewport_size.x,
620            (1.0 - ndc.y) * 0.5 * viewport_size.y,
621        );
622        if screen.x >= rect_min.x
623            && screen.x <= rect_max.x
624            && screen.y >= rect_min.y
625            && screen.y <= rect_max.y
626        {
627            hits.push(obj.id());
628        }
629    }
630    hits
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636    use crate::scene::traits::ViewportObject;
637    use std::collections::HashMap;
638
639    struct TestObject {
640        id: u64,
641        mesh_id: u64,
642        position: glam::Vec3,
643        visible: bool,
644    }
645
646    impl ViewportObject for TestObject {
647        fn id(&self) -> u64 {
648            self.id
649        }
650        fn mesh_id(&self) -> Option<u64> {
651            Some(self.mesh_id)
652        }
653        fn model_matrix(&self) -> glam::Mat4 {
654            glam::Mat4::from_translation(self.position)
655        }
656        fn position(&self) -> glam::Vec3 {
657            self.position
658        }
659        fn rotation(&self) -> glam::Quat {
660            glam::Quat::IDENTITY
661        }
662        fn is_visible(&self) -> bool {
663            self.visible
664        }
665        fn color(&self) -> glam::Vec3 {
666            glam::Vec3::ONE
667        }
668    }
669
670    /// Unit cube centered at origin: 8 vertices, 12 triangles.
671    fn unit_cube_mesh() -> (Vec<[f32; 3]>, Vec<u32>) {
672        let positions = vec![
673            [-0.5, -0.5, -0.5],
674            [0.5, -0.5, -0.5],
675            [0.5, 0.5, -0.5],
676            [-0.5, 0.5, -0.5],
677            [-0.5, -0.5, 0.5],
678            [0.5, -0.5, 0.5],
679            [0.5, 0.5, 0.5],
680            [-0.5, 0.5, 0.5],
681        ];
682        let indices = vec![
683            0, 1, 2, 2, 3, 0, // front
684            4, 6, 5, 6, 4, 7, // back
685            0, 3, 7, 7, 4, 0, // left
686            1, 5, 6, 6, 2, 1, // right
687            3, 2, 6, 6, 7, 3, // top
688            0, 4, 5, 5, 1, 0, // bottom
689        ];
690        (positions, indices)
691    }
692
693    #[test]
694    fn test_screen_to_ray_center() {
695        // Identity view-proj: screen center should produce a ray along -Z.
696        let vp_inv = glam::Mat4::IDENTITY;
697        let (origin, dir) = screen_to_ray(
698            glam::Vec2::new(400.0, 300.0),
699            glam::Vec2::new(800.0, 600.0),
700            vp_inv,
701        );
702        // NDC (0,0) -> origin at (0,0,0), direction toward (0,0,1).
703        assert!(origin.x.abs() < 1e-3, "origin.x={}", origin.x);
704        assert!(origin.y.abs() < 1e-3, "origin.y={}", origin.y);
705        assert!(dir.z.abs() > 0.9, "dir should be along Z, got {dir:?}");
706    }
707
708    #[test]
709    fn test_pick_scene_hit() {
710        let (positions, indices) = unit_cube_mesh();
711        let mut mesh_lookup = HashMap::new();
712        mesh_lookup.insert(1u64, (positions, indices));
713
714        let obj = TestObject {
715            id: 42,
716            mesh_id: 1,
717            position: glam::Vec3::ZERO,
718            visible: true,
719        };
720        let objects: Vec<&dyn ViewportObject> = vec![&obj];
721
722        // Ray from +Z toward origin should hit the cube.
723        let result = pick_scene(
724            glam::Vec3::new(0.0, 0.0, 5.0),
725            glam::Vec3::new(0.0, 0.0, -1.0),
726            &objects,
727            &mesh_lookup,
728        );
729        assert!(result.is_some(), "expected a hit");
730        let hit = result.unwrap();
731        assert_eq!(hit.id, 42);
732        // Front face of unit cube at origin is at z=0.5; ray from z=5 hits at toi=4.5.
733        assert!(
734            (hit.world_pos.z - 0.5).abs() < 0.01,
735            "world_pos.z={}",
736            hit.world_pos.z
737        );
738        // Normal should point roughly toward +Z (toward camera).
739        assert!(hit.normal.z > 0.9, "normal={:?}", hit.normal);
740    }
741
742    #[test]
743    fn test_pick_scene_miss() {
744        let (positions, indices) = unit_cube_mesh();
745        let mut mesh_lookup = HashMap::new();
746        mesh_lookup.insert(1u64, (positions, indices));
747
748        let obj = TestObject {
749            id: 42,
750            mesh_id: 1,
751            position: glam::Vec3::ZERO,
752            visible: true,
753        };
754        let objects: Vec<&dyn ViewportObject> = vec![&obj];
755
756        // Ray far from geometry should miss.
757        let result = pick_scene(
758            glam::Vec3::new(100.0, 100.0, 5.0),
759            glam::Vec3::new(0.0, 0.0, -1.0),
760            &objects,
761            &mesh_lookup,
762        );
763        assert!(result.is_none());
764    }
765
766    #[test]
767    fn test_pick_nearest_wins() {
768        let (positions, indices) = unit_cube_mesh();
769        let mut mesh_lookup = HashMap::new();
770        mesh_lookup.insert(1u64, (positions.clone(), indices.clone()));
771        mesh_lookup.insert(2u64, (positions, indices));
772
773        let near_obj = TestObject {
774            id: 10,
775            mesh_id: 1,
776            position: glam::Vec3::new(0.0, 0.0, 2.0),
777            visible: true,
778        };
779        let far_obj = TestObject {
780            id: 20,
781            mesh_id: 2,
782            position: glam::Vec3::new(0.0, 0.0, -2.0),
783            visible: true,
784        };
785        let objects: Vec<&dyn ViewportObject> = vec![&far_obj, &near_obj];
786
787        // Ray from +Z toward -Z should hit the nearer object first.
788        let result = pick_scene(
789            glam::Vec3::new(0.0, 0.0, 10.0),
790            glam::Vec3::new(0.0, 0.0, -1.0),
791            &objects,
792            &mesh_lookup,
793        );
794        assert!(result.is_some(), "expected a hit");
795        assert_eq!(result.unwrap().id, 10);
796    }
797
798    #[test]
799    fn test_box_select_hits_inside_rect() {
800        // Place object at origin, use an identity-like view_proj so it projects to screen center.
801        let view = glam::Mat4::look_at_rh(
802            glam::Vec3::new(0.0, 0.0, 5.0),
803            glam::Vec3::ZERO,
804            glam::Vec3::Y,
805        );
806        let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
807        let vp = proj * view;
808        let viewport_size = glam::Vec2::new(800.0, 600.0);
809
810        let obj = TestObject {
811            id: 42,
812            mesh_id: 1,
813            position: glam::Vec3::ZERO,
814            visible: true,
815        };
816        let objects: Vec<&dyn ViewportObject> = vec![&obj];
817
818        // Rectangle around screen center should capture the object.
819        let result = box_select(
820            glam::Vec2::new(300.0, 200.0),
821            glam::Vec2::new(500.0, 400.0),
822            &objects,
823            vp,
824            viewport_size,
825        );
826        assert_eq!(result, vec![42]);
827    }
828
829    #[test]
830    fn test_box_select_skips_hidden() {
831        let view = glam::Mat4::look_at_rh(
832            glam::Vec3::new(0.0, 0.0, 5.0),
833            glam::Vec3::ZERO,
834            glam::Vec3::Y,
835        );
836        let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
837        let vp = proj * view;
838        let viewport_size = glam::Vec2::new(800.0, 600.0);
839
840        let obj = TestObject {
841            id: 42,
842            mesh_id: 1,
843            position: glam::Vec3::ZERO,
844            visible: false,
845        };
846        let objects: Vec<&dyn ViewportObject> = vec![&obj];
847
848        let result = box_select(
849            glam::Vec2::new(0.0, 0.0),
850            glam::Vec2::new(800.0, 600.0),
851            &objects,
852            vp,
853            viewport_size,
854        );
855        assert!(result.is_empty());
856    }
857
858    #[test]
859    fn test_pick_scene_nodes_hit() {
860        let (positions, indices) = unit_cube_mesh();
861        let mut mesh_lookup = HashMap::new();
862        mesh_lookup.insert(0u64, (positions, indices));
863
864        let mut scene = crate::scene::scene::Scene::new();
865        scene.add(
866            Some(crate::resources::mesh_store::MeshId(0)),
867            glam::Mat4::IDENTITY,
868            crate::scene::material::Material::default(),
869        );
870        scene.update_transforms();
871
872        let result = pick_scene_nodes(
873            glam::Vec3::new(0.0, 0.0, 5.0),
874            glam::Vec3::new(0.0, 0.0, -1.0),
875            &scene,
876            &mesh_lookup,
877        );
878        assert!(result.is_some());
879    }
880
881    #[test]
882    fn test_pick_scene_nodes_miss() {
883        let (positions, indices) = unit_cube_mesh();
884        let mut mesh_lookup = HashMap::new();
885        mesh_lookup.insert(0u64, (positions, indices));
886
887        let mut scene = crate::scene::scene::Scene::new();
888        scene.add(
889            Some(crate::resources::mesh_store::MeshId(0)),
890            glam::Mat4::IDENTITY,
891            crate::scene::material::Material::default(),
892        );
893        scene.update_transforms();
894
895        let result = pick_scene_nodes(
896            glam::Vec3::new(100.0, 100.0, 5.0),
897            glam::Vec3::new(0.0, 0.0, -1.0),
898            &scene,
899            &mesh_lookup,
900        );
901        assert!(result.is_none());
902    }
903
904    #[test]
905    fn test_probe_vertex_attribute() {
906        let (positions, indices) = unit_cube_mesh();
907        let mut mesh_lookup = HashMap::new();
908        mesh_lookup.insert(1u64, (positions.clone(), indices.clone()));
909
910        // Assign a per-vertex scalar: value = vertex index as f32.
911        let vertex_scalars: Vec<f32> = (0..positions.len()).map(|i| i as f32).collect();
912
913        let obj = TestObject {
914            id: 42,
915            mesh_id: 1,
916            position: glam::Vec3::ZERO,
917            visible: true,
918        };
919        let objects: Vec<&dyn ViewportObject> = vec![&obj];
920
921        let attr_ref = AttributeRef {
922            name: "test".to_string(),
923            kind: AttributeKind::Vertex,
924        };
925        let attr_data = AttributeData::Vertex(vertex_scalars);
926        let bindings = vec![ProbeBinding {
927            id: 42,
928            attribute_ref: &attr_ref,
929            attribute_data: &attr_data,
930            positions: &positions,
931            indices: &indices,
932        }];
933
934        let result = pick_scene_with_probe(
935            glam::Vec3::new(0.0, 0.0, 5.0),
936            glam::Vec3::new(0.0, 0.0, -1.0),
937            &objects,
938            &mesh_lookup,
939            &bindings,
940        );
941        assert!(result.is_some(), "expected a hit");
942        let hit = result.unwrap();
943        assert_eq!(hit.id, 42);
944        // scalar_value should be populated (interpolated from vertex scalars).
945        assert!(
946            hit.scalar_value.is_some(),
947            "expected scalar_value to be set"
948        );
949    }
950
951    #[test]
952    fn test_probe_cell_attribute() {
953        let (positions, indices) = unit_cube_mesh();
954        let mut mesh_lookup = HashMap::new();
955        mesh_lookup.insert(1u64, (positions.clone(), indices.clone()));
956
957        // 12 triangles in a unit cube : assign each a scalar.
958        let num_triangles = indices.len() / 3;
959        let cell_scalars: Vec<f32> = (0..num_triangles).map(|i| (i as f32) * 10.0).collect();
960
961        let obj = TestObject {
962            id: 42,
963            mesh_id: 1,
964            position: glam::Vec3::ZERO,
965            visible: true,
966        };
967        let objects: Vec<&dyn ViewportObject> = vec![&obj];
968
969        let attr_ref = AttributeRef {
970            name: "pressure".to_string(),
971            kind: AttributeKind::Cell,
972        };
973        let attr_data = AttributeData::Cell(cell_scalars.clone());
974        let bindings = vec![ProbeBinding {
975            id: 42,
976            attribute_ref: &attr_ref,
977            attribute_data: &attr_data,
978            positions: &positions,
979            indices: &indices,
980        }];
981
982        let result = pick_scene_with_probe(
983            glam::Vec3::new(0.0, 0.0, 5.0),
984            glam::Vec3::new(0.0, 0.0, -1.0),
985            &objects,
986            &mesh_lookup,
987            &bindings,
988        );
989        assert!(result.is_some());
990        let hit = result.unwrap();
991        // Cell attribute value should be one of the triangle scalars.
992        assert!(hit.scalar_value.is_some());
993        let val = hit.scalar_value.unwrap();
994        assert!(
995            cell_scalars.contains(&val),
996            "scalar_value {val} not in cell_scalars"
997        );
998    }
999
1000    #[test]
1001    fn test_probe_no_binding_leaves_none() {
1002        let (positions, indices) = unit_cube_mesh();
1003        let mut mesh_lookup = HashMap::new();
1004        mesh_lookup.insert(1u64, (positions, indices));
1005
1006        let obj = TestObject {
1007            id: 42,
1008            mesh_id: 1,
1009            position: glam::Vec3::ZERO,
1010            visible: true,
1011        };
1012        let objects: Vec<&dyn ViewportObject> = vec![&obj];
1013
1014        // No probe bindings : scalar_value should remain None.
1015        let result = pick_scene_with_probe(
1016            glam::Vec3::new(0.0, 0.0, 5.0),
1017            glam::Vec3::new(0.0, 0.0, -1.0),
1018            &objects,
1019            &mesh_lookup,
1020            &[],
1021        );
1022        assert!(result.is_some());
1023        assert!(result.unwrap().scalar_value.is_none());
1024    }
1025
1026    // ---------------------------------------------------------------------------
1027    // pick_rect tests
1028    // ---------------------------------------------------------------------------
1029
1030    /// Build a simple perspective view_proj looking at the origin from +Z.
1031    fn make_view_proj() -> glam::Mat4 {
1032        let view = glam::Mat4::look_at_rh(
1033            glam::Vec3::new(0.0, 0.0, 5.0),
1034            glam::Vec3::ZERO,
1035            glam::Vec3::Y,
1036        );
1037        let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
1038        proj * view
1039    }
1040
1041    #[test]
1042    fn test_pick_rect_mesh_full_screen() {
1043        // A full-screen rect should capture all triangle centroids of the unit cube.
1044        let (positions, indices) = unit_cube_mesh();
1045        let mut mesh_lookup: std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)> =
1046            std::collections::HashMap::new();
1047        mesh_lookup.insert(0, (positions, indices.clone()));
1048
1049        let item = crate::renderer::SceneRenderItem {
1050            mesh_id: crate::resources::mesh_store::MeshId(0),
1051            model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1052            visible: true,
1053            ..Default::default()
1054        };
1055
1056        let view_proj = make_view_proj();
1057        let viewport = glam::Vec2::new(800.0, 600.0);
1058
1059        let result = pick_rect(
1060            glam::Vec2::ZERO,
1061            viewport,
1062            &[item],
1063            &mesh_lookup,
1064            &[],
1065            view_proj,
1066            viewport,
1067        );
1068
1069        // The cube has 12 triangles; front-facing ones project inside the full-screen rect.
1070        assert!(!result.is_empty(), "expected at least one triangle hit");
1071        assert!(result.total_count() > 0);
1072    }
1073
1074    #[test]
1075    fn test_pick_rect_miss() {
1076        // A rect far off-screen should return empty results.
1077        let (positions, indices) = unit_cube_mesh();
1078        let mut mesh_lookup: std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)> =
1079            std::collections::HashMap::new();
1080        mesh_lookup.insert(0, (positions, indices));
1081
1082        let item = crate::renderer::SceneRenderItem {
1083            mesh_id: crate::resources::mesh_store::MeshId(0),
1084            model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1085            visible: true,
1086            ..Default::default()
1087        };
1088
1089        let view_proj = make_view_proj();
1090        let viewport = glam::Vec2::new(800.0, 600.0);
1091
1092        let result = pick_rect(
1093            glam::Vec2::new(700.0, 500.0), // bottom-right corner, cube projects to center
1094            glam::Vec2::new(799.0, 599.0),
1095            &[item],
1096            &mesh_lookup,
1097            &[],
1098            view_proj,
1099            viewport,
1100        );
1101
1102        assert!(result.is_empty(), "expected no hits in off-center rect");
1103    }
1104
1105    #[test]
1106    fn test_pick_rect_point_cloud() {
1107        // Points at the origin should be captured by a full-screen rect.
1108        let view_proj = make_view_proj();
1109        let viewport = glam::Vec2::new(800.0, 600.0);
1110
1111        let pc = crate::renderer::PointCloudItem {
1112            positions: vec![[0.0, 0.0, 0.0], [0.1, 0.1, 0.0]],
1113            model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1114            id: 99,
1115            ..Default::default()
1116        };
1117
1118        let result = pick_rect(
1119            glam::Vec2::ZERO,
1120            viewport,
1121            &[],
1122            &std::collections::HashMap::new(),
1123            &[pc],
1124            view_proj,
1125            viewport,
1126        );
1127
1128        assert!(!result.is_empty(), "expected point cloud hits");
1129        let hits = result.hits.get(&99).expect("expected hits for id 99");
1130        assert_eq!(
1131            hits.len(),
1132            2,
1133            "both points should be inside the full-screen rect"
1134        );
1135        // Verify the hits are typed as Point sub-objects.
1136        assert!(
1137            hits.iter().all(|s| s.is_point()),
1138            "expected SubObjectRef::Point entries"
1139        );
1140        assert_eq!(hits[0], SubObjectRef::Point(0));
1141        assert_eq!(hits[1], SubObjectRef::Point(1));
1142    }
1143
1144    #[test]
1145    fn test_pick_rect_skips_invisible() {
1146        let (positions, indices) = unit_cube_mesh();
1147        let mut mesh_lookup: std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)> =
1148            std::collections::HashMap::new();
1149        mesh_lookup.insert(0, (positions, indices));
1150
1151        let item = crate::renderer::SceneRenderItem {
1152            mesh_id: crate::resources::mesh_store::MeshId(0),
1153            model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1154            visible: false, // hidden
1155            ..Default::default()
1156        };
1157
1158        let view_proj = make_view_proj();
1159        let viewport = glam::Vec2::new(800.0, 600.0);
1160
1161        let result = pick_rect(
1162            glam::Vec2::ZERO,
1163            viewport,
1164            &[item],
1165            &mesh_lookup,
1166            &[],
1167            view_proj,
1168            viewport,
1169        );
1170
1171        assert!(result.is_empty(), "invisible items should be skipped");
1172    }
1173
1174    #[test]
1175    fn test_pick_rect_result_type() {
1176        // Verify RectPickResult accessors.
1177        let mut r = RectPickResult::default();
1178        assert!(r.is_empty());
1179        assert_eq!(r.total_count(), 0);
1180
1181        r.hits.insert(
1182            1,
1183            vec![
1184                SubObjectRef::Face(0),
1185                SubObjectRef::Face(1),
1186                SubObjectRef::Face(2),
1187            ],
1188        );
1189        r.hits.insert(2, vec![SubObjectRef::Point(5)]);
1190        assert!(!r.is_empty());
1191        assert_eq!(r.total_count(), 4);
1192    }
1193
1194    #[test]
1195    fn test_barycentric_at_vertices() {
1196        let a = glam::Vec3::new(0.0, 0.0, 0.0);
1197        let b = glam::Vec3::new(1.0, 0.0, 0.0);
1198        let c = glam::Vec3::new(0.0, 1.0, 0.0);
1199
1200        // At vertex a: u=1, v=0, w=0.
1201        let (u, v, w) = super::barycentric(a, a, b, c);
1202        assert!((u - 1.0).abs() < 1e-5, "u={u}");
1203        assert!(v.abs() < 1e-5, "v={v}");
1204        assert!(w.abs() < 1e-5, "w={w}");
1205
1206        // At vertex b: u=0, v=1, w=0.
1207        let (u, v, w) = super::barycentric(b, a, b, c);
1208        assert!(u.abs() < 1e-5, "u={u}");
1209        assert!((v - 1.0).abs() < 1e-5, "v={v}");
1210        assert!(w.abs() < 1e-5, "w={w}");
1211
1212        // At centroid: u=v=w≈1/3.
1213        let centroid = (a + b + c) / 3.0;
1214        let (u, v, w) = super::barycentric(centroid, a, b, c);
1215        assert!((u - 1.0 / 3.0).abs() < 1e-4, "u={u}");
1216        assert!((v - 1.0 / 3.0).abs() < 1e-4, "v={v}");
1217        assert!((w - 1.0 / 3.0).abs() < 1e-4, "w={w}");
1218    }
1219}