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