1use 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#[derive(Clone, Copy, Debug)]
20#[non_exhaustive]
21pub struct PickHit {
22 pub id: u64,
24 pub sub_object: Option<SubObjectRef>,
29 pub world_pos: glam::Vec3,
31 pub normal: glam::Vec3,
33 #[deprecated(since = "0.5.0", note = "use `sub_object` instead")]
38 pub triangle_index: u32,
39 #[deprecated(since = "0.5.0", note = "use `sub_object` instead")]
44 pub point_index: Option<u32>,
45 pub scalar_value: Option<f32>,
52}
53
54#[derive(Clone, Copy, Debug)]
66#[non_exhaustive]
67pub struct GpuPickHit {
68 pub object_id: u64,
75 pub depth: f32,
84}
85
86pub 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; 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
111pub 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 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 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 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 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
206pub 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
220pub struct ProbeBinding<'a> {
229 pub id: u64,
231 pub attribute_ref: &'a AttributeRef,
233 pub attribute_data: &'a AttributeData,
235 pub positions: &'a [[f32; 3]],
237 pub indices: &'a [u32],
239}
240
241fn 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 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
266fn 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 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 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 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
323pub 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
343pub 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
360pub 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#[derive(Clone, Debug, Default)]
389pub struct RectPickResult {
390 pub hits: std::collections::HashMap<u64, Vec<SubObjectRef>>,
397}
398
399impl RectPickResult {
400 pub fn is_empty(&self) -> bool {
402 self.hits.is_empty()
403 }
404
405 pub fn total_count(&self) -> usize {
407 self.hits.values().map(|v| v.len()).sum()
408 }
409}
410
411pub 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 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, );
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, );
447
448 let mut result = RectPickResult::default();
449
450 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 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 for pc in point_clouds {
501 if pc.id == 0 {
502 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
533pub 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 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 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, 4, 6, 5, 6, 4, 7, 0, 3, 7, 7, 4, 0, 1, 5, 6, 6, 2, 1, 3, 2, 6, 6, 7, 3, 0, 4, 5, 5, 1, 0, ];
632 (positions, indices)
633 }
634
635 #[test]
636 fn test_screen_to_ray_center() {
637 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 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 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 assert!(
676 (hit.world_pos.z - 0.5).abs() < 0.01,
677 "world_pos.z={}",
678 hit.world_pos.z
679 );
680 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 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 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 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 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 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 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 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 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 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 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 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 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 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), 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 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 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, ..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 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 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 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 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}