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. A value of `0` is never returned (non-pickable surfaces are
73    /// excluded from the pick pass).
74    pub object_id: u64,
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    }
332}
333
334/// Like [`pick_scene`] but also computes the scalar attribute value at the hit
335/// point via barycentric interpolation (vertex attributes) or direct lookup
336/// (cell attributes).
337///
338/// `probe_bindings` maps object IDs to their active attribute data. If the hit
339/// object has no matching binding, `PickHit::scalar_value` remains `None`.
340pub fn pick_scene_with_probe(
341    ray_origin: glam::Vec3,
342    ray_dir: glam::Vec3,
343    objects: &[&dyn ViewportObject],
344    mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
345    probe_bindings: &[ProbeBinding<'_>],
346) -> Option<PickHit> {
347    let mut hit = pick_scene(ray_origin, ray_dir, objects, mesh_lookup)?;
348    if let Some(binding) = probe_bindings.iter().find(|b| b.id == hit.id) {
349        probe_scalar(&mut hit, binding);
350    }
351    Some(hit)
352}
353
354/// Like [`pick_scene_nodes`] but also computes the scalar value at the hit point.
355///
356/// See [`pick_scene_with_probe`] for details on probe bindings.
357pub fn pick_scene_nodes_with_probe(
358    ray_origin: glam::Vec3,
359    ray_dir: glam::Vec3,
360    scene: &crate::scene::scene::Scene,
361    mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
362    probe_bindings: &[ProbeBinding<'_>],
363) -> Option<PickHit> {
364    let mut hit = pick_scene_nodes(ray_origin, ray_dir, scene, mesh_lookup)?;
365    if let Some(binding) = probe_bindings.iter().find(|b| b.id == hit.id) {
366        probe_scalar(&mut hit, binding);
367    }
368    Some(hit)
369}
370
371/// Like [`pick_scene_accelerated`](crate::geometry::bvh::pick_scene_accelerated) but also
372/// computes the scalar value at the hit point.
373///
374/// See [`pick_scene_with_probe`] for details on probe bindings.
375pub fn pick_scene_accelerated_with_probe(
376    ray_origin: glam::Vec3,
377    ray_dir: glam::Vec3,
378    accelerator: &mut crate::geometry::bvh::PickAccelerator,
379    mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
380    probe_bindings: &[ProbeBinding<'_>],
381) -> Option<PickHit> {
382    let mut hit = accelerator.pick(ray_origin, ray_dir, mesh_lookup)?;
383    if let Some(binding) = probe_bindings.iter().find(|b| b.id == hit.id) {
384        probe_scalar(&mut hit, binding);
385    }
386    Some(hit)
387}
388
389// ---------------------------------------------------------------------------
390// RectPickResult — rubber-band / sub-object selection
391// ---------------------------------------------------------------------------
392
393/// Result of a rectangular (rubber-band) pick.
394///
395/// Maps each hit object's identifier to the typed sub-object references that
396/// fall inside the selection rectangle:
397/// - For mesh objects: [`SubObjectRef::Face`] entries whose centroid projects inside the rect.
398/// - For point clouds: [`SubObjectRef::Point`] entries whose position projects inside the rect.
399#[derive(Clone, Debug, Default)]
400pub struct RectPickResult {
401    /// Per-object typed sub-object references.
402    ///
403    /// Key = object identifier (`mesh_index` cast to `u64` for scene items,
404    /// [`crate::renderer::PointCloudItem::id`] for point clouds).
405    /// Value = [`SubObjectRef`]s inside the rect — `Face` for mesh triangles,
406    /// `Point` for point cloud points.
407    pub hits: std::collections::HashMap<u64, Vec<SubObjectRef>>,
408}
409
410impl RectPickResult {
411    /// Returns `true` when no objects were hit.
412    pub fn is_empty(&self) -> bool {
413        self.hits.is_empty()
414    }
415
416    /// Total number of sub-object indices across all hit objects.
417    pub fn total_count(&self) -> usize {
418        self.hits.values().map(|v| v.len()).sum()
419    }
420}
421
422/// Sub-object (triangle / point) selection inside a screen-space rectangle.
423///
424/// Projects triangle centroids (for mesh scene items) and point positions (for
425/// point clouds) through `view_proj`, then tests NDC containment against the
426/// rectangle defined by `rect_min`..`rect_max` (viewport-local pixels, top-left
427/// origin).
428///
429/// This is a **pure CPU** operation — no GPU readback is required.
430///
431/// # Arguments
432/// * `rect_min` — top-left corner of the selection rect in viewport pixels
433/// * `rect_max` — bottom-right corner of the selection rect in viewport pixels
434/// * `scene_items` — visible scene render items for this frame
435/// * `mesh_lookup` — CPU-side mesh data keyed by `SceneRenderItem::mesh_index`
436/// * `point_clouds` — point cloud items for this frame
437/// * `view_proj` — combined view × projection matrix
438/// * `viewport_size` — viewport width × height in pixels
439pub fn pick_rect(
440    rect_min: glam::Vec2,
441    rect_max: glam::Vec2,
442    scene_items: &[crate::renderer::SceneRenderItem],
443    mesh_lookup: &std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)>,
444    point_clouds: &[crate::renderer::PointCloudItem],
445    view_proj: glam::Mat4,
446    viewport_size: glam::Vec2,
447) -> RectPickResult {
448    // Convert screen rect to NDC rect.
449    // Screen: x right, y down. NDC: x right, y up.
450    let ndc_min = glam::Vec2::new(
451        rect_min.x / viewport_size.x * 2.0 - 1.0,
452        1.0 - rect_max.y / viewport_size.y * 2.0, // rect_max.y is the bottom in screen space
453    );
454    let ndc_max = glam::Vec2::new(
455        rect_max.x / viewport_size.x * 2.0 - 1.0,
456        1.0 - rect_min.y / viewport_size.y * 2.0, // rect_min.y is the top in screen space
457    );
458
459    let mut result = RectPickResult::default();
460
461    // --- Mesh scene items ---
462    for item in scene_items {
463        if !item.visible {
464            continue;
465        }
466        let Some((positions, indices)) = mesh_lookup.get(&item.mesh_index) else {
467            continue;
468        };
469
470        let model = glam::Mat4::from_cols_array_2d(&item.model);
471        let mvp = view_proj * model;
472
473        let mut tri_hits: Vec<SubObjectRef> = Vec::new();
474
475        for (tri_idx, chunk) in indices.chunks(3).enumerate() {
476            if chunk.len() < 3 {
477                continue;
478            }
479            let i0 = chunk[0] as usize;
480            let i1 = chunk[1] as usize;
481            let i2 = chunk[2] as usize;
482
483            if i0 >= positions.len() || i1 >= positions.len() || i2 >= positions.len() {
484                continue;
485            }
486
487            let p0 = glam::Vec3::from(positions[i0]);
488            let p1 = glam::Vec3::from(positions[i1]);
489            let p2 = glam::Vec3::from(positions[i2]);
490            let centroid = (p0 + p1 + p2) / 3.0;
491
492            let clip = mvp * centroid.extend(1.0);
493            if clip.w <= 0.0 {
494                // Behind the camera — skip.
495                continue;
496            }
497            let ndc = glam::Vec2::new(clip.x / clip.w, clip.y / clip.w);
498
499            if ndc.x >= ndc_min.x && ndc.x <= ndc_max.x && ndc.y >= ndc_min.y && ndc.y <= ndc_max.y
500            {
501                tri_hits.push(SubObjectRef::Face(tri_idx as u32));
502            }
503        }
504
505        if !tri_hits.is_empty() {
506            result.hits.insert(item.mesh_index as u64, tri_hits);
507        }
508    }
509
510    // --- Point cloud items ---
511    for pc in point_clouds {
512        if pc.id == 0 {
513            // Not pickable.
514            continue;
515        }
516
517        let model = glam::Mat4::from_cols_array_2d(&pc.model);
518        let mvp = view_proj * model;
519
520        let mut pt_hits: Vec<SubObjectRef> = Vec::new();
521
522        for (pt_idx, pos) in pc.positions.iter().enumerate() {
523            let p = glam::Vec3::from(*pos);
524            let clip = mvp * p.extend(1.0);
525            if clip.w <= 0.0 {
526                continue;
527            }
528            let ndc = glam::Vec2::new(clip.x / clip.w, clip.y / clip.w);
529
530            if ndc.x >= ndc_min.x && ndc.x <= ndc_max.x && ndc.y >= ndc_min.y && ndc.y <= ndc_max.y
531            {
532                pt_hits.push(SubObjectRef::Point(pt_idx as u32));
533            }
534        }
535
536        if !pt_hits.is_empty() {
537            result.hits.insert(pc.id, pt_hits);
538        }
539    }
540
541    result
542}
543
544/// Select all visible objects whose world-space position projects inside a
545/// screen-space rectangle.
546///
547/// Projects each object's position via `view_proj` to screen coordinates,
548/// then tests containment in the rectangle defined by `rect_min`..`rect_max`
549/// (in viewport-local pixels, top-left origin).
550///
551/// Returns the IDs of all objects inside the rectangle.
552pub fn box_select(
553    rect_min: glam::Vec2,
554    rect_max: glam::Vec2,
555    objects: &[&dyn ViewportObject],
556    view_proj: glam::Mat4,
557    viewport_size: glam::Vec2,
558) -> Vec<u64> {
559    let mut hits = Vec::new();
560    for obj in objects {
561        if !obj.is_visible() {
562            continue;
563        }
564        let pos = obj.position();
565        let clip = view_proj * pos.extend(1.0);
566        // Behind the camera — skip.
567        if clip.w <= 0.0 {
568            continue;
569        }
570        let ndc = glam::Vec3::new(clip.x / clip.w, clip.y / clip.w, clip.z / clip.w);
571        let screen = glam::Vec2::new(
572            (ndc.x + 1.0) * 0.5 * viewport_size.x,
573            (1.0 - ndc.y) * 0.5 * viewport_size.y,
574        );
575        if screen.x >= rect_min.x
576            && screen.x <= rect_max.x
577            && screen.y >= rect_min.y
578            && screen.y <= rect_max.y
579        {
580            hits.push(obj.id());
581        }
582    }
583    hits
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589    use crate::scene::traits::ViewportObject;
590    use std::collections::HashMap;
591
592    struct TestObject {
593        id: u64,
594        mesh_id: u64,
595        position: glam::Vec3,
596        visible: bool,
597    }
598
599    impl ViewportObject for TestObject {
600        fn id(&self) -> u64 {
601            self.id
602        }
603        fn mesh_id(&self) -> Option<u64> {
604            Some(self.mesh_id)
605        }
606        fn model_matrix(&self) -> glam::Mat4 {
607            glam::Mat4::from_translation(self.position)
608        }
609        fn position(&self) -> glam::Vec3 {
610            self.position
611        }
612        fn rotation(&self) -> glam::Quat {
613            glam::Quat::IDENTITY
614        }
615        fn is_visible(&self) -> bool {
616            self.visible
617        }
618        fn color(&self) -> glam::Vec3 {
619            glam::Vec3::ONE
620        }
621    }
622
623    /// Unit cube centered at origin: 8 vertices, 12 triangles.
624    fn unit_cube_mesh() -> (Vec<[f32; 3]>, Vec<u32>) {
625        let positions = vec![
626            [-0.5, -0.5, -0.5],
627            [0.5, -0.5, -0.5],
628            [0.5, 0.5, -0.5],
629            [-0.5, 0.5, -0.5],
630            [-0.5, -0.5, 0.5],
631            [0.5, -0.5, 0.5],
632            [0.5, 0.5, 0.5],
633            [-0.5, 0.5, 0.5],
634        ];
635        let indices = vec![
636            0, 1, 2, 2, 3, 0, // front
637            4, 6, 5, 6, 4, 7, // back
638            0, 3, 7, 7, 4, 0, // left
639            1, 5, 6, 6, 2, 1, // right
640            3, 2, 6, 6, 7, 3, // top
641            0, 4, 5, 5, 1, 0, // bottom
642        ];
643        (positions, indices)
644    }
645
646    #[test]
647    fn test_screen_to_ray_center() {
648        // Identity view-proj: screen center should produce a ray along -Z.
649        let vp_inv = glam::Mat4::IDENTITY;
650        let (origin, dir) = screen_to_ray(
651            glam::Vec2::new(400.0, 300.0),
652            glam::Vec2::new(800.0, 600.0),
653            vp_inv,
654        );
655        // NDC (0,0) -> origin at (0,0,0), direction toward (0,0,1).
656        assert!(origin.x.abs() < 1e-3, "origin.x={}", origin.x);
657        assert!(origin.y.abs() < 1e-3, "origin.y={}", origin.y);
658        assert!(dir.z.abs() > 0.9, "dir should be along Z, got {dir:?}");
659    }
660
661    #[test]
662    fn test_pick_scene_hit() {
663        let (positions, indices) = unit_cube_mesh();
664        let mut mesh_lookup = HashMap::new();
665        mesh_lookup.insert(1u64, (positions, indices));
666
667        let obj = TestObject {
668            id: 42,
669            mesh_id: 1,
670            position: glam::Vec3::ZERO,
671            visible: true,
672        };
673        let objects: Vec<&dyn ViewportObject> = vec![&obj];
674
675        // Ray from +Z toward origin should hit the cube.
676        let result = pick_scene(
677            glam::Vec3::new(0.0, 0.0, 5.0),
678            glam::Vec3::new(0.0, 0.0, -1.0),
679            &objects,
680            &mesh_lookup,
681        );
682        assert!(result.is_some(), "expected a hit");
683        let hit = result.unwrap();
684        assert_eq!(hit.id, 42);
685        // Front face of unit cube at origin is at z=0.5; ray from z=5 hits at toi=4.5.
686        assert!(
687            (hit.world_pos.z - 0.5).abs() < 0.01,
688            "world_pos.z={}",
689            hit.world_pos.z
690        );
691        // Normal should point roughly toward +Z (toward camera).
692        assert!(hit.normal.z > 0.9, "normal={:?}", hit.normal);
693    }
694
695    #[test]
696    fn test_pick_scene_miss() {
697        let (positions, indices) = unit_cube_mesh();
698        let mut mesh_lookup = HashMap::new();
699        mesh_lookup.insert(1u64, (positions, indices));
700
701        let obj = TestObject {
702            id: 42,
703            mesh_id: 1,
704            position: glam::Vec3::ZERO,
705            visible: true,
706        };
707        let objects: Vec<&dyn ViewportObject> = vec![&obj];
708
709        // Ray far from geometry should miss.
710        let result = pick_scene(
711            glam::Vec3::new(100.0, 100.0, 5.0),
712            glam::Vec3::new(0.0, 0.0, -1.0),
713            &objects,
714            &mesh_lookup,
715        );
716        assert!(result.is_none());
717    }
718
719    #[test]
720    fn test_pick_nearest_wins() {
721        let (positions, indices) = unit_cube_mesh();
722        let mut mesh_lookup = HashMap::new();
723        mesh_lookup.insert(1u64, (positions.clone(), indices.clone()));
724        mesh_lookup.insert(2u64, (positions, indices));
725
726        let near_obj = TestObject {
727            id: 10,
728            mesh_id: 1,
729            position: glam::Vec3::new(0.0, 0.0, 2.0),
730            visible: true,
731        };
732        let far_obj = TestObject {
733            id: 20,
734            mesh_id: 2,
735            position: glam::Vec3::new(0.0, 0.0, -2.0),
736            visible: true,
737        };
738        let objects: Vec<&dyn ViewportObject> = vec![&far_obj, &near_obj];
739
740        // Ray from +Z toward -Z should hit the nearer object first.
741        let result = pick_scene(
742            glam::Vec3::new(0.0, 0.0, 10.0),
743            glam::Vec3::new(0.0, 0.0, -1.0),
744            &objects,
745            &mesh_lookup,
746        );
747        assert!(result.is_some(), "expected a hit");
748        assert_eq!(result.unwrap().id, 10);
749    }
750
751    #[test]
752    fn test_box_select_hits_inside_rect() {
753        // Place object at origin, use an identity-like view_proj so it projects to screen center.
754        let view = glam::Mat4::look_at_rh(
755            glam::Vec3::new(0.0, 0.0, 5.0),
756            glam::Vec3::ZERO,
757            glam::Vec3::Y,
758        );
759        let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
760        let vp = proj * view;
761        let viewport_size = glam::Vec2::new(800.0, 600.0);
762
763        let obj = TestObject {
764            id: 42,
765            mesh_id: 1,
766            position: glam::Vec3::ZERO,
767            visible: true,
768        };
769        let objects: Vec<&dyn ViewportObject> = vec![&obj];
770
771        // Rectangle around screen center should capture the object.
772        let result = box_select(
773            glam::Vec2::new(300.0, 200.0),
774            glam::Vec2::new(500.0, 400.0),
775            &objects,
776            vp,
777            viewport_size,
778        );
779        assert_eq!(result, vec![42]);
780    }
781
782    #[test]
783    fn test_box_select_skips_hidden() {
784        let view = glam::Mat4::look_at_rh(
785            glam::Vec3::new(0.0, 0.0, 5.0),
786            glam::Vec3::ZERO,
787            glam::Vec3::Y,
788        );
789        let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
790        let vp = proj * view;
791        let viewport_size = glam::Vec2::new(800.0, 600.0);
792
793        let obj = TestObject {
794            id: 42,
795            mesh_id: 1,
796            position: glam::Vec3::ZERO,
797            visible: false,
798        };
799        let objects: Vec<&dyn ViewportObject> = vec![&obj];
800
801        let result = box_select(
802            glam::Vec2::new(0.0, 0.0),
803            glam::Vec2::new(800.0, 600.0),
804            &objects,
805            vp,
806            viewport_size,
807        );
808        assert!(result.is_empty());
809    }
810
811    #[test]
812    fn test_pick_scene_nodes_hit() {
813        let (positions, indices) = unit_cube_mesh();
814        let mut mesh_lookup = HashMap::new();
815        mesh_lookup.insert(0u64, (positions, indices));
816
817        let mut scene = crate::scene::scene::Scene::new();
818        scene.add(
819            Some(crate::resources::mesh_store::MeshId(0)),
820            glam::Mat4::IDENTITY,
821            crate::scene::material::Material::default(),
822        );
823        scene.update_transforms();
824
825        let result = pick_scene_nodes(
826            glam::Vec3::new(0.0, 0.0, 5.0),
827            glam::Vec3::new(0.0, 0.0, -1.0),
828            &scene,
829            &mesh_lookup,
830        );
831        assert!(result.is_some());
832    }
833
834    #[test]
835    fn test_pick_scene_nodes_miss() {
836        let (positions, indices) = unit_cube_mesh();
837        let mut mesh_lookup = HashMap::new();
838        mesh_lookup.insert(0u64, (positions, indices));
839
840        let mut scene = crate::scene::scene::Scene::new();
841        scene.add(
842            Some(crate::resources::mesh_store::MeshId(0)),
843            glam::Mat4::IDENTITY,
844            crate::scene::material::Material::default(),
845        );
846        scene.update_transforms();
847
848        let result = pick_scene_nodes(
849            glam::Vec3::new(100.0, 100.0, 5.0),
850            glam::Vec3::new(0.0, 0.0, -1.0),
851            &scene,
852            &mesh_lookup,
853        );
854        assert!(result.is_none());
855    }
856
857    #[test]
858    fn test_probe_vertex_attribute() {
859        let (positions, indices) = unit_cube_mesh();
860        let mut mesh_lookup = HashMap::new();
861        mesh_lookup.insert(1u64, (positions.clone(), indices.clone()));
862
863        // Assign a per-vertex scalar: value = vertex index as f32.
864        let vertex_scalars: Vec<f32> = (0..positions.len()).map(|i| i as f32).collect();
865
866        let obj = TestObject {
867            id: 42,
868            mesh_id: 1,
869            position: glam::Vec3::ZERO,
870            visible: true,
871        };
872        let objects: Vec<&dyn ViewportObject> = vec![&obj];
873
874        let attr_ref = AttributeRef {
875            name: "test".to_string(),
876            kind: AttributeKind::Vertex,
877        };
878        let attr_data = AttributeData::Vertex(vertex_scalars);
879        let bindings = vec![ProbeBinding {
880            id: 42,
881            attribute_ref: &attr_ref,
882            attribute_data: &attr_data,
883            positions: &positions,
884            indices: &indices,
885        }];
886
887        let result = pick_scene_with_probe(
888            glam::Vec3::new(0.0, 0.0, 5.0),
889            glam::Vec3::new(0.0, 0.0, -1.0),
890            &objects,
891            &mesh_lookup,
892            &bindings,
893        );
894        assert!(result.is_some(), "expected a hit");
895        let hit = result.unwrap();
896        assert_eq!(hit.id, 42);
897        // scalar_value should be populated (interpolated from vertex scalars).
898        assert!(
899            hit.scalar_value.is_some(),
900            "expected scalar_value to be set"
901        );
902    }
903
904    #[test]
905    fn test_probe_cell_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        // 12 triangles in a unit cube — assign each a scalar.
911        let num_triangles = indices.len() / 3;
912        let cell_scalars: Vec<f32> = (0..num_triangles).map(|i| (i as f32) * 10.0).collect();
913
914        let obj = TestObject {
915            id: 42,
916            mesh_id: 1,
917            position: glam::Vec3::ZERO,
918            visible: true,
919        };
920        let objects: Vec<&dyn ViewportObject> = vec![&obj];
921
922        let attr_ref = AttributeRef {
923            name: "pressure".to_string(),
924            kind: AttributeKind::Cell,
925        };
926        let attr_data = AttributeData::Cell(cell_scalars.clone());
927        let bindings = vec![ProbeBinding {
928            id: 42,
929            attribute_ref: &attr_ref,
930            attribute_data: &attr_data,
931            positions: &positions,
932            indices: &indices,
933        }];
934
935        let result = pick_scene_with_probe(
936            glam::Vec3::new(0.0, 0.0, 5.0),
937            glam::Vec3::new(0.0, 0.0, -1.0),
938            &objects,
939            &mesh_lookup,
940            &bindings,
941        );
942        assert!(result.is_some());
943        let hit = result.unwrap();
944        // Cell attribute value should be one of the triangle scalars.
945        assert!(hit.scalar_value.is_some());
946        let val = hit.scalar_value.unwrap();
947        assert!(
948            cell_scalars.contains(&val),
949            "scalar_value {val} not in cell_scalars"
950        );
951    }
952
953    #[test]
954    fn test_probe_no_binding_leaves_none() {
955        let (positions, indices) = unit_cube_mesh();
956        let mut mesh_lookup = HashMap::new();
957        mesh_lookup.insert(1u64, (positions, indices));
958
959        let obj = TestObject {
960            id: 42,
961            mesh_id: 1,
962            position: glam::Vec3::ZERO,
963            visible: true,
964        };
965        let objects: Vec<&dyn ViewportObject> = vec![&obj];
966
967        // No probe bindings — scalar_value should remain None.
968        let result = pick_scene_with_probe(
969            glam::Vec3::new(0.0, 0.0, 5.0),
970            glam::Vec3::new(0.0, 0.0, -1.0),
971            &objects,
972            &mesh_lookup,
973            &[],
974        );
975        assert!(result.is_some());
976        assert!(result.unwrap().scalar_value.is_none());
977    }
978
979    // ---------------------------------------------------------------------------
980    // pick_rect tests
981    // ---------------------------------------------------------------------------
982
983    /// Build a simple perspective view_proj looking at the origin from +Z.
984    fn make_view_proj() -> glam::Mat4 {
985        let view = glam::Mat4::look_at_rh(
986            glam::Vec3::new(0.0, 0.0, 5.0),
987            glam::Vec3::ZERO,
988            glam::Vec3::Y,
989        );
990        let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
991        proj * view
992    }
993
994    #[test]
995    fn test_pick_rect_mesh_full_screen() {
996        // A full-screen rect should capture all triangle centroids of the unit cube.
997        let (positions, indices) = unit_cube_mesh();
998        let mut mesh_lookup: std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)> =
999            std::collections::HashMap::new();
1000        mesh_lookup.insert(0, (positions, indices.clone()));
1001
1002        let item = crate::renderer::SceneRenderItem {
1003            mesh_index: 0,
1004            model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1005            visible: true,
1006            ..Default::default()
1007        };
1008
1009        let view_proj = make_view_proj();
1010        let viewport = glam::Vec2::new(800.0, 600.0);
1011
1012        let result = pick_rect(
1013            glam::Vec2::ZERO,
1014            viewport,
1015            &[item],
1016            &mesh_lookup,
1017            &[],
1018            view_proj,
1019            viewport,
1020        );
1021
1022        // The cube has 12 triangles; front-facing ones project inside the full-screen rect.
1023        assert!(!result.is_empty(), "expected at least one triangle hit");
1024        assert!(result.total_count() > 0);
1025    }
1026
1027    #[test]
1028    fn test_pick_rect_miss() {
1029        // A rect far off-screen should return empty results.
1030        let (positions, indices) = unit_cube_mesh();
1031        let mut mesh_lookup: std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)> =
1032            std::collections::HashMap::new();
1033        mesh_lookup.insert(0, (positions, indices));
1034
1035        let item = crate::renderer::SceneRenderItem {
1036            mesh_index: 0,
1037            model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1038            visible: true,
1039            ..Default::default()
1040        };
1041
1042        let view_proj = make_view_proj();
1043        let viewport = glam::Vec2::new(800.0, 600.0);
1044
1045        let result = pick_rect(
1046            glam::Vec2::new(700.0, 500.0), // bottom-right corner, cube projects to center
1047            glam::Vec2::new(799.0, 599.0),
1048            &[item],
1049            &mesh_lookup,
1050            &[],
1051            view_proj,
1052            viewport,
1053        );
1054
1055        assert!(result.is_empty(), "expected no hits in off-center rect");
1056    }
1057
1058    #[test]
1059    fn test_pick_rect_point_cloud() {
1060        // Points at the origin should be captured by a full-screen rect.
1061        let view_proj = make_view_proj();
1062        let viewport = glam::Vec2::new(800.0, 600.0);
1063
1064        let pc = crate::renderer::PointCloudItem {
1065            positions: vec![[0.0, 0.0, 0.0], [0.1, 0.1, 0.0]],
1066            model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1067            id: 99,
1068            ..Default::default()
1069        };
1070
1071        let result = pick_rect(
1072            glam::Vec2::ZERO,
1073            viewport,
1074            &[],
1075            &std::collections::HashMap::new(),
1076            &[pc],
1077            view_proj,
1078            viewport,
1079        );
1080
1081        assert!(!result.is_empty(), "expected point cloud hits");
1082        let hits = result.hits.get(&99).expect("expected hits for id 99");
1083        assert_eq!(
1084            hits.len(),
1085            2,
1086            "both points should be inside the full-screen rect"
1087        );
1088        // Verify the hits are typed as Point sub-objects.
1089        assert!(
1090            hits.iter().all(|s| s.is_point()),
1091            "expected SubObjectRef::Point entries"
1092        );
1093        assert_eq!(hits[0], SubObjectRef::Point(0));
1094        assert_eq!(hits[1], SubObjectRef::Point(1));
1095    }
1096
1097    #[test]
1098    fn test_pick_rect_skips_invisible() {
1099        let (positions, indices) = unit_cube_mesh();
1100        let mut mesh_lookup: std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)> =
1101            std::collections::HashMap::new();
1102        mesh_lookup.insert(0, (positions, indices));
1103
1104        let item = crate::renderer::SceneRenderItem {
1105            mesh_index: 0,
1106            model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1107            visible: false, // hidden
1108            ..Default::default()
1109        };
1110
1111        let view_proj = make_view_proj();
1112        let viewport = glam::Vec2::new(800.0, 600.0);
1113
1114        let result = pick_rect(
1115            glam::Vec2::ZERO,
1116            viewport,
1117            &[item],
1118            &mesh_lookup,
1119            &[],
1120            view_proj,
1121            viewport,
1122        );
1123
1124        assert!(result.is_empty(), "invisible items should be skipped");
1125    }
1126
1127    #[test]
1128    fn test_pick_rect_result_type() {
1129        // Verify RectPickResult accessors.
1130        let mut r = RectPickResult::default();
1131        assert!(r.is_empty());
1132        assert_eq!(r.total_count(), 0);
1133
1134        r.hits.insert(
1135            1,
1136            vec![
1137                SubObjectRef::Face(0),
1138                SubObjectRef::Face(1),
1139                SubObjectRef::Face(2),
1140            ],
1141        );
1142        r.hits.insert(2, vec![SubObjectRef::Point(5)]);
1143        assert!(!r.is_empty());
1144        assert_eq!(r.total_count(), 4);
1145    }
1146
1147    #[test]
1148    fn test_barycentric_at_vertices() {
1149        let a = glam::Vec3::new(0.0, 0.0, 0.0);
1150        let b = glam::Vec3::new(1.0, 0.0, 0.0);
1151        let c = glam::Vec3::new(0.0, 1.0, 0.0);
1152
1153        // At vertex a: u=1, v=0, w=0.
1154        let (u, v, w) = super::barycentric(a, a, b, c);
1155        assert!((u - 1.0).abs() < 1e-5, "u={u}");
1156        assert!(v.abs() < 1e-5, "v={v}");
1157        assert!(w.abs() < 1e-5, "w={w}");
1158
1159        // At vertex b: u=0, v=1, w=0.
1160        let (u, v, w) = super::barycentric(b, a, b, c);
1161        assert!(u.abs() < 1e-5, "u={u}");
1162        assert!((v - 1.0).abs() < 1e-5, "v={v}");
1163        assert!(w.abs() < 1e-5, "w={w}");
1164
1165        // At centroid: u=v=w≈1/3.
1166        let centroid = (a + b + c) / 3.0;
1167        let (u, v, w) = super::barycentric(centroid, a, b, c);
1168        assert!((u - 1.0 / 3.0).abs() < 1e-4, "u={u}");
1169        assert!((v - 1.0 / 3.0).abs() < 1e-4, "v={v}");
1170        assert!((w - 1.0 / 3.0).abs() < 1e-4, "w={w}");
1171    }
1172}