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