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::Face => {
293 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 }
303 AttributeKind::Vertex => {
304 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
334pub 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
354pub 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
371pub 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#[derive(Clone, Debug, Default)]
400pub struct RectPickResult {
401 pub hits: std::collections::HashMap<u64, Vec<SubObjectRef>>,
408}
409
410impl RectPickResult {
411 pub fn is_empty(&self) -> bool {
413 self.hits.is_empty()
414 }
415
416 pub fn total_count(&self) -> usize {
418 self.hits.values().map(|v| v.len()).sum()
419 }
420}
421
422pub 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 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, );
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, );
458
459 let mut result = RectPickResult::default();
460
461 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 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 for pc in point_clouds {
512 if pc.id == 0 {
513 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
544pub 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 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 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, 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, ];
643 (positions, indices)
644 }
645
646 #[test]
647 fn test_screen_to_ray_center() {
648 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 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 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 assert!(
687 (hit.world_pos.z - 0.5).abs() < 0.01,
688 "world_pos.z={}",
689 hit.world_pos.z
690 );
691 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 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 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 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 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 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 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 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 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 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 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 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 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 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), 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 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 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, ..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 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 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 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 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}