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