1use crate::resources::{AttributeData, AttributeKind, AttributeRef};
6use crate::scene::traits::ViewportObject;
7use parry3d::math::{Pose, Vector};
8use parry3d::query::{Ray, RayCast};
9use parry3d::shape::FeatureId;
10
11#[derive(Clone, Copy, Debug)]
20#[non_exhaustive]
21pub struct PickHit {
22 pub id: u64,
24 pub triangle_index: u32,
27 pub world_pos: glam::Vec3,
29 pub normal: glam::Vec3,
31 pub point_index: Option<u32>,
34 pub scalar_value: Option<f32>,
41}
42
43#[derive(Clone, Copy, Debug)]
55#[non_exhaustive]
56pub struct GpuPickHit {
57 pub object_id: u64,
64 pub depth: f32,
73}
74
75pub fn screen_to_ray(
88 screen_pos: glam::Vec2,
89 viewport_size: glam::Vec2,
90 view_proj_inv: glam::Mat4,
91) -> (glam::Vec3, glam::Vec3) {
92 let ndc_x = (screen_pos.x / viewport_size.x) * 2.0 - 1.0;
93 let ndc_y = 1.0 - (screen_pos.y / viewport_size.y) * 2.0; let near = view_proj_inv.project_point3(glam::Vec3::new(ndc_x, ndc_y, 0.0));
95 let far = view_proj_inv.project_point3(glam::Vec3::new(ndc_x, ndc_y, 1.0));
96 let dir = (far - near).normalize();
97 (near, dir)
98}
99
100pub fn pick_scene(
109 ray_origin: glam::Vec3,
110 ray_dir: glam::Vec3,
111 objects: &[&dyn ViewportObject],
112 mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
113) -> Option<PickHit> {
114 let ray = Ray::new(
116 Vector::new(ray_origin.x, ray_origin.y, ray_origin.z),
117 Vector::new(ray_dir.x, ray_dir.y, ray_dir.z),
118 );
119
120 let mut best_hit: Option<(u64, f32, PickHit)> = None;
121
122 for obj in objects {
123 if !obj.is_visible() {
124 continue;
125 }
126 let Some(mesh_id) = obj.mesh_id() else {
127 continue;
128 };
129
130 if let Some((positions, indices)) = mesh_lookup.get(&mesh_id) {
131 let s = obj.scale();
136 let verts: Vec<Vector> = positions
137 .iter()
138 .map(|p: &[f32; 3]| Vector::new(p[0] * s.x, p[1] * s.y, p[2] * s.z))
139 .collect();
140
141 let tri_indices: Vec<[u32; 3]> = indices
142 .chunks(3)
143 .filter(|c: &&[u32]| c.len() == 3)
144 .map(|c: &[u32]| [c[0], c[1], c[2]])
145 .collect();
146
147 if tri_indices.is_empty() {
148 continue;
149 }
150
151 match parry3d::shape::TriMesh::new(verts, tri_indices) {
152 Ok(trimesh) => {
153 let pose = Pose::from_parts(obj.position(), obj.rotation());
157 if let Some(intersection) =
158 trimesh.cast_ray_and_get_normal(&pose, &ray, f32::MAX, true)
159 {
160 let toi = intersection.time_of_impact;
161 if best_hit.is_none() || toi < best_hit.as_ref().unwrap().1 {
162 let triangle_index = match intersection.feature {
163 FeatureId::Face(idx) => idx,
164 _ => u32::MAX,
165 };
166 let world_pos = ray_origin + ray_dir * toi;
167 let normal = intersection.normal;
169 best_hit = Some((
170 obj.id(),
171 toi,
172 PickHit {
173 id: obj.id(),
174 triangle_index,
175 world_pos,
176 normal,
177 point_index: None,
178 scalar_value: None,
179 },
180 ));
181 }
182 }
183 }
184 Err(e) => {
185 tracing::warn!(object_id = obj.id(), error = %e, "TriMesh construction failed for picking");
186 }
187 }
188 }
189 }
190
191 best_hit.map(|(_, _, hit)| hit)
192}
193
194pub fn pick_scene_nodes(
199 ray_origin: glam::Vec3,
200 ray_dir: glam::Vec3,
201 scene: &crate::scene::scene::Scene,
202 mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
203) -> Option<PickHit> {
204 let nodes: Vec<&dyn ViewportObject> = scene.nodes().map(|n| n as &dyn ViewportObject).collect();
205 pick_scene(ray_origin, ray_dir, &nodes, mesh_lookup)
206}
207
208pub struct ProbeBinding<'a> {
217 pub id: u64,
219 pub attribute_ref: &'a AttributeRef,
221 pub attribute_data: &'a AttributeData,
223 pub positions: &'a [[f32; 3]],
225 pub indices: &'a [u32],
227}
228
229fn barycentric(p: glam::Vec3, a: glam::Vec3, b: glam::Vec3, c: glam::Vec3) -> (f32, f32, f32) {
234 let v0 = b - a;
235 let v1 = c - a;
236 let v2 = p - a;
237 let d00 = v0.dot(v0);
238 let d01 = v0.dot(v1);
239 let d11 = v1.dot(v1);
240 let d20 = v2.dot(v0);
241 let d21 = v2.dot(v1);
242 let denom = d00 * d11 - d01 * d01;
243 if denom.abs() < 1e-12 {
244 return (1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0);
246 }
247 let inv = 1.0 / denom;
248 let v = (d11 * d20 - d01 * d21) * inv;
249 let w = (d00 * d21 - d01 * d20) * inv;
250 let u = 1.0 - v - w;
251 (u, v, w)
252}
253
254fn probe_scalar(hit: &mut PickHit, binding: &ProbeBinding<'_>) {
257 if hit.triangle_index == u32::MAX {
258 return;
259 }
260
261 let num_triangles = binding.indices.len() / 3;
262 let tri_idx = if (hit.triangle_index as usize) >= num_triangles && num_triangles > 0 {
265 hit.triangle_index as usize - num_triangles
266 } else {
267 hit.triangle_index as usize
268 };
269
270 match binding.attribute_ref.kind {
271 AttributeKind::Cell => {
272 if let AttributeData::Cell(data) = binding.attribute_data {
274 if let Some(&val) = data.get(tri_idx) {
275 hit.scalar_value = Some(val);
276 }
277 }
278 }
279 AttributeKind::Vertex => {
280 if let AttributeData::Vertex(data) = binding.attribute_data {
282 let base = tri_idx * 3;
283 if base + 2 >= binding.indices.len() {
284 return;
285 }
286 let i0 = binding.indices[base] as usize;
287 let i1 = binding.indices[base + 1] as usize;
288 let i2 = binding.indices[base + 2] as usize;
289
290 if i0 >= data.len() || i1 >= data.len() || i2 >= data.len() {
291 return;
292 }
293 if i0 >= binding.positions.len()
294 || i1 >= binding.positions.len()
295 || i2 >= binding.positions.len()
296 {
297 return;
298 }
299
300 let a = glam::Vec3::from(binding.positions[i0]);
301 let b = glam::Vec3::from(binding.positions[i1]);
302 let c = glam::Vec3::from(binding.positions[i2]);
303 let (u, v, w) = barycentric(hit.world_pos, a, b, c);
304 hit.scalar_value = Some(data[i0] * u + data[i1] * v + data[i2] * w);
305 }
306 }
307 }
308}
309
310pub fn pick_scene_with_probe(
317 ray_origin: glam::Vec3,
318 ray_dir: glam::Vec3,
319 objects: &[&dyn ViewportObject],
320 mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
321 probe_bindings: &[ProbeBinding<'_>],
322) -> Option<PickHit> {
323 let mut hit = pick_scene(ray_origin, ray_dir, objects, mesh_lookup)?;
324 if let Some(binding) = probe_bindings.iter().find(|b| b.id == hit.id) {
325 probe_scalar(&mut hit, binding);
326 }
327 Some(hit)
328}
329
330pub fn pick_scene_nodes_with_probe(
334 ray_origin: glam::Vec3,
335 ray_dir: glam::Vec3,
336 scene: &crate::scene::scene::Scene,
337 mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
338 probe_bindings: &[ProbeBinding<'_>],
339) -> Option<PickHit> {
340 let mut hit = pick_scene_nodes(ray_origin, ray_dir, scene, mesh_lookup)?;
341 if let Some(binding) = probe_bindings.iter().find(|b| b.id == hit.id) {
342 probe_scalar(&mut hit, binding);
343 }
344 Some(hit)
345}
346
347pub fn pick_scene_accelerated_with_probe(
352 ray_origin: glam::Vec3,
353 ray_dir: glam::Vec3,
354 accelerator: &mut crate::geometry::bvh::PickAccelerator,
355 mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
356 probe_bindings: &[ProbeBinding<'_>],
357) -> Option<PickHit> {
358 let mut hit = accelerator.pick(ray_origin, ray_dir, mesh_lookup)?;
359 if let Some(binding) = probe_bindings.iter().find(|b| b.id == hit.id) {
360 probe_scalar(&mut hit, binding);
361 }
362 Some(hit)
363}
364
365#[derive(Clone, Debug, Default)]
376pub struct RectPickResult {
377 pub hits: std::collections::HashMap<u64, Vec<u32>>,
383}
384
385impl RectPickResult {
386 pub fn is_empty(&self) -> bool {
388 self.hits.is_empty()
389 }
390
391 pub fn total_count(&self) -> usize {
393 self.hits.values().map(|v| v.len()).sum()
394 }
395}
396
397pub fn pick_rect(
415 rect_min: glam::Vec2,
416 rect_max: glam::Vec2,
417 scene_items: &[crate::renderer::SceneRenderItem],
418 mesh_lookup: &std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)>,
419 point_clouds: &[crate::renderer::PointCloudItem],
420 view_proj: glam::Mat4,
421 viewport_size: glam::Vec2,
422) -> RectPickResult {
423 let ndc_min = glam::Vec2::new(
426 rect_min.x / viewport_size.x * 2.0 - 1.0,
427 1.0 - rect_max.y / viewport_size.y * 2.0, );
429 let ndc_max = glam::Vec2::new(
430 rect_max.x / viewport_size.x * 2.0 - 1.0,
431 1.0 - rect_min.y / viewport_size.y * 2.0, );
433
434 let mut result = RectPickResult::default();
435
436 for item in scene_items {
438 if !item.visible {
439 continue;
440 }
441 let Some((positions, indices)) = mesh_lookup.get(&item.mesh_index) else {
442 continue;
443 };
444
445 let model = glam::Mat4::from_cols_array_2d(&item.model);
446 let mvp = view_proj * model;
447
448 let mut tri_hits: Vec<u32> = Vec::new();
449
450 for (tri_idx, chunk) in indices.chunks(3).enumerate() {
451 if chunk.len() < 3 {
452 continue;
453 }
454 let i0 = chunk[0] as usize;
455 let i1 = chunk[1] as usize;
456 let i2 = chunk[2] as usize;
457
458 if i0 >= positions.len() || i1 >= positions.len() || i2 >= positions.len() {
459 continue;
460 }
461
462 let p0 = glam::Vec3::from(positions[i0]);
463 let p1 = glam::Vec3::from(positions[i1]);
464 let p2 = glam::Vec3::from(positions[i2]);
465 let centroid = (p0 + p1 + p2) / 3.0;
466
467 let clip = mvp * centroid.extend(1.0);
468 if clip.w <= 0.0 {
469 continue;
471 }
472 let ndc = glam::Vec2::new(clip.x / clip.w, clip.y / clip.w);
473
474 if ndc.x >= ndc_min.x && ndc.x <= ndc_max.x && ndc.y >= ndc_min.y && ndc.y <= ndc_max.y
475 {
476 tri_hits.push(tri_idx as u32);
477 }
478 }
479
480 if !tri_hits.is_empty() {
481 result.hits.insert(item.mesh_index as u64, tri_hits);
482 }
483 }
484
485 for pc in point_clouds {
487 if pc.id == 0 {
488 continue;
490 }
491
492 let model = glam::Mat4::from_cols_array_2d(&pc.model);
493 let mvp = view_proj * model;
494
495 let mut pt_hits: Vec<u32> = Vec::new();
496
497 for (pt_idx, pos) in pc.positions.iter().enumerate() {
498 let p = glam::Vec3::from(*pos);
499 let clip = mvp * p.extend(1.0);
500 if clip.w <= 0.0 {
501 continue;
502 }
503 let ndc = glam::Vec2::new(clip.x / clip.w, clip.y / clip.w);
504
505 if ndc.x >= ndc_min.x && ndc.x <= ndc_max.x && ndc.y >= ndc_min.y && ndc.y <= ndc_max.y
506 {
507 pt_hits.push(pt_idx as u32);
508 }
509 }
510
511 if !pt_hits.is_empty() {
512 result.hits.insert(pc.id, pt_hits);
513 }
514 }
515
516 result
517}
518
519pub fn box_select(
528 rect_min: glam::Vec2,
529 rect_max: glam::Vec2,
530 objects: &[&dyn ViewportObject],
531 view_proj: glam::Mat4,
532 viewport_size: glam::Vec2,
533) -> Vec<u64> {
534 let mut hits = Vec::new();
535 for obj in objects {
536 if !obj.is_visible() {
537 continue;
538 }
539 let pos = obj.position();
540 let clip = view_proj * pos.extend(1.0);
541 if clip.w <= 0.0 {
543 continue;
544 }
545 let ndc = glam::Vec3::new(clip.x / clip.w, clip.y / clip.w, clip.z / clip.w);
546 let screen = glam::Vec2::new(
547 (ndc.x + 1.0) * 0.5 * viewport_size.x,
548 (1.0 - ndc.y) * 0.5 * viewport_size.y,
549 );
550 if screen.x >= rect_min.x
551 && screen.x <= rect_max.x
552 && screen.y >= rect_min.y
553 && screen.y <= rect_max.y
554 {
555 hits.push(obj.id());
556 }
557 }
558 hits
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564 use crate::scene::traits::ViewportObject;
565 use std::collections::HashMap;
566
567 struct TestObject {
568 id: u64,
569 mesh_id: u64,
570 position: glam::Vec3,
571 visible: bool,
572 }
573
574 impl ViewportObject for TestObject {
575 fn id(&self) -> u64 {
576 self.id
577 }
578 fn mesh_id(&self) -> Option<u64> {
579 Some(self.mesh_id)
580 }
581 fn model_matrix(&self) -> glam::Mat4 {
582 glam::Mat4::from_translation(self.position)
583 }
584 fn position(&self) -> glam::Vec3 {
585 self.position
586 }
587 fn rotation(&self) -> glam::Quat {
588 glam::Quat::IDENTITY
589 }
590 fn is_visible(&self) -> bool {
591 self.visible
592 }
593 fn color(&self) -> glam::Vec3 {
594 glam::Vec3::ONE
595 }
596 }
597
598 fn unit_cube_mesh() -> (Vec<[f32; 3]>, Vec<u32>) {
600 let positions = vec![
601 [-0.5, -0.5, -0.5],
602 [0.5, -0.5, -0.5],
603 [0.5, 0.5, -0.5],
604 [-0.5, 0.5, -0.5],
605 [-0.5, -0.5, 0.5],
606 [0.5, -0.5, 0.5],
607 [0.5, 0.5, 0.5],
608 [-0.5, 0.5, 0.5],
609 ];
610 let indices = vec![
611 0, 1, 2, 2, 3, 0, 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, ];
618 (positions, indices)
619 }
620
621 #[test]
622 fn test_screen_to_ray_center() {
623 let vp_inv = glam::Mat4::IDENTITY;
625 let (origin, dir) = screen_to_ray(
626 glam::Vec2::new(400.0, 300.0),
627 glam::Vec2::new(800.0, 600.0),
628 vp_inv,
629 );
630 assert!(origin.x.abs() < 1e-3, "origin.x={}", origin.x);
632 assert!(origin.y.abs() < 1e-3, "origin.y={}", origin.y);
633 assert!(dir.z.abs() > 0.9, "dir should be along Z, got {dir:?}");
634 }
635
636 #[test]
637 fn test_pick_scene_hit() {
638 let (positions, indices) = unit_cube_mesh();
639 let mut mesh_lookup = HashMap::new();
640 mesh_lookup.insert(1u64, (positions, indices));
641
642 let obj = TestObject {
643 id: 42,
644 mesh_id: 1,
645 position: glam::Vec3::ZERO,
646 visible: true,
647 };
648 let objects: Vec<&dyn ViewportObject> = vec![&obj];
649
650 let result = pick_scene(
652 glam::Vec3::new(0.0, 0.0, 5.0),
653 glam::Vec3::new(0.0, 0.0, -1.0),
654 &objects,
655 &mesh_lookup,
656 );
657 assert!(result.is_some(), "expected a hit");
658 let hit = result.unwrap();
659 assert_eq!(hit.id, 42);
660 assert!(
662 (hit.world_pos.z - 0.5).abs() < 0.01,
663 "world_pos.z={}",
664 hit.world_pos.z
665 );
666 assert!(hit.normal.z > 0.9, "normal={:?}", hit.normal);
668 }
669
670 #[test]
671 fn test_pick_scene_miss() {
672 let (positions, indices) = unit_cube_mesh();
673 let mut mesh_lookup = HashMap::new();
674 mesh_lookup.insert(1u64, (positions, indices));
675
676 let obj = TestObject {
677 id: 42,
678 mesh_id: 1,
679 position: glam::Vec3::ZERO,
680 visible: true,
681 };
682 let objects: Vec<&dyn ViewportObject> = vec![&obj];
683
684 let result = pick_scene(
686 glam::Vec3::new(100.0, 100.0, 5.0),
687 glam::Vec3::new(0.0, 0.0, -1.0),
688 &objects,
689 &mesh_lookup,
690 );
691 assert!(result.is_none());
692 }
693
694 #[test]
695 fn test_pick_nearest_wins() {
696 let (positions, indices) = unit_cube_mesh();
697 let mut mesh_lookup = HashMap::new();
698 mesh_lookup.insert(1u64, (positions.clone(), indices.clone()));
699 mesh_lookup.insert(2u64, (positions, indices));
700
701 let near_obj = TestObject {
702 id: 10,
703 mesh_id: 1,
704 position: glam::Vec3::new(0.0, 0.0, 2.0),
705 visible: true,
706 };
707 let far_obj = TestObject {
708 id: 20,
709 mesh_id: 2,
710 position: glam::Vec3::new(0.0, 0.0, -2.0),
711 visible: true,
712 };
713 let objects: Vec<&dyn ViewportObject> = vec![&far_obj, &near_obj];
714
715 let result = pick_scene(
717 glam::Vec3::new(0.0, 0.0, 10.0),
718 glam::Vec3::new(0.0, 0.0, -1.0),
719 &objects,
720 &mesh_lookup,
721 );
722 assert!(result.is_some(), "expected a hit");
723 assert_eq!(result.unwrap().id, 10);
724 }
725
726 #[test]
727 fn test_box_select_hits_inside_rect() {
728 let view = glam::Mat4::look_at_rh(
730 glam::Vec3::new(0.0, 0.0, 5.0),
731 glam::Vec3::ZERO,
732 glam::Vec3::Y,
733 );
734 let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
735 let vp = proj * view;
736 let viewport_size = glam::Vec2::new(800.0, 600.0);
737
738 let obj = TestObject {
739 id: 42,
740 mesh_id: 1,
741 position: glam::Vec3::ZERO,
742 visible: true,
743 };
744 let objects: Vec<&dyn ViewportObject> = vec![&obj];
745
746 let result = box_select(
748 glam::Vec2::new(300.0, 200.0),
749 glam::Vec2::new(500.0, 400.0),
750 &objects,
751 vp,
752 viewport_size,
753 );
754 assert_eq!(result, vec![42]);
755 }
756
757 #[test]
758 fn test_box_select_skips_hidden() {
759 let view = glam::Mat4::look_at_rh(
760 glam::Vec3::new(0.0, 0.0, 5.0),
761 glam::Vec3::ZERO,
762 glam::Vec3::Y,
763 );
764 let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
765 let vp = proj * view;
766 let viewport_size = glam::Vec2::new(800.0, 600.0);
767
768 let obj = TestObject {
769 id: 42,
770 mesh_id: 1,
771 position: glam::Vec3::ZERO,
772 visible: false,
773 };
774 let objects: Vec<&dyn ViewportObject> = vec![&obj];
775
776 let result = box_select(
777 glam::Vec2::new(0.0, 0.0),
778 glam::Vec2::new(800.0, 600.0),
779 &objects,
780 vp,
781 viewport_size,
782 );
783 assert!(result.is_empty());
784 }
785
786 #[test]
787 fn test_pick_scene_nodes_hit() {
788 let (positions, indices) = unit_cube_mesh();
789 let mut mesh_lookup = HashMap::new();
790 mesh_lookup.insert(0u64, (positions, indices));
791
792 let mut scene = crate::scene::scene::Scene::new();
793 scene.add(
794 Some(crate::resources::mesh_store::MeshId(0)),
795 glam::Mat4::IDENTITY,
796 crate::scene::material::Material::default(),
797 );
798 scene.update_transforms();
799
800 let result = pick_scene_nodes(
801 glam::Vec3::new(0.0, 0.0, 5.0),
802 glam::Vec3::new(0.0, 0.0, -1.0),
803 &scene,
804 &mesh_lookup,
805 );
806 assert!(result.is_some());
807 }
808
809 #[test]
810 fn test_pick_scene_nodes_miss() {
811 let (positions, indices) = unit_cube_mesh();
812 let mut mesh_lookup = HashMap::new();
813 mesh_lookup.insert(0u64, (positions, indices));
814
815 let mut scene = crate::scene::scene::Scene::new();
816 scene.add(
817 Some(crate::resources::mesh_store::MeshId(0)),
818 glam::Mat4::IDENTITY,
819 crate::scene::material::Material::default(),
820 );
821 scene.update_transforms();
822
823 let result = pick_scene_nodes(
824 glam::Vec3::new(100.0, 100.0, 5.0),
825 glam::Vec3::new(0.0, 0.0, -1.0),
826 &scene,
827 &mesh_lookup,
828 );
829 assert!(result.is_none());
830 }
831
832 #[test]
833 fn test_probe_vertex_attribute() {
834 let (positions, indices) = unit_cube_mesh();
835 let mut mesh_lookup = HashMap::new();
836 mesh_lookup.insert(1u64, (positions.clone(), indices.clone()));
837
838 let vertex_scalars: Vec<f32> = (0..positions.len()).map(|i| i as f32).collect();
840
841 let obj = TestObject {
842 id: 42,
843 mesh_id: 1,
844 position: glam::Vec3::ZERO,
845 visible: true,
846 };
847 let objects: Vec<&dyn ViewportObject> = vec![&obj];
848
849 let attr_ref = AttributeRef {
850 name: "test".to_string(),
851 kind: AttributeKind::Vertex,
852 };
853 let attr_data = AttributeData::Vertex(vertex_scalars);
854 let bindings = vec![ProbeBinding {
855 id: 42,
856 attribute_ref: &attr_ref,
857 attribute_data: &attr_data,
858 positions: &positions,
859 indices: &indices,
860 }];
861
862 let result = pick_scene_with_probe(
863 glam::Vec3::new(0.0, 0.0, 5.0),
864 glam::Vec3::new(0.0, 0.0, -1.0),
865 &objects,
866 &mesh_lookup,
867 &bindings,
868 );
869 assert!(result.is_some(), "expected a hit");
870 let hit = result.unwrap();
871 assert_eq!(hit.id, 42);
872 assert!(
874 hit.scalar_value.is_some(),
875 "expected scalar_value to be set"
876 );
877 }
878
879 #[test]
880 fn test_probe_cell_attribute() {
881 let (positions, indices) = unit_cube_mesh();
882 let mut mesh_lookup = HashMap::new();
883 mesh_lookup.insert(1u64, (positions.clone(), indices.clone()));
884
885 let num_triangles = indices.len() / 3;
887 let cell_scalars: Vec<f32> = (0..num_triangles).map(|i| (i as f32) * 10.0).collect();
888
889 let obj = TestObject {
890 id: 42,
891 mesh_id: 1,
892 position: glam::Vec3::ZERO,
893 visible: true,
894 };
895 let objects: Vec<&dyn ViewportObject> = vec![&obj];
896
897 let attr_ref = AttributeRef {
898 name: "pressure".to_string(),
899 kind: AttributeKind::Cell,
900 };
901 let attr_data = AttributeData::Cell(cell_scalars.clone());
902 let bindings = vec![ProbeBinding {
903 id: 42,
904 attribute_ref: &attr_ref,
905 attribute_data: &attr_data,
906 positions: &positions,
907 indices: &indices,
908 }];
909
910 let result = pick_scene_with_probe(
911 glam::Vec3::new(0.0, 0.0, 5.0),
912 glam::Vec3::new(0.0, 0.0, -1.0),
913 &objects,
914 &mesh_lookup,
915 &bindings,
916 );
917 assert!(result.is_some());
918 let hit = result.unwrap();
919 assert!(hit.scalar_value.is_some());
921 let val = hit.scalar_value.unwrap();
922 assert!(
923 cell_scalars.contains(&val),
924 "scalar_value {val} not in cell_scalars"
925 );
926 }
927
928 #[test]
929 fn test_probe_no_binding_leaves_none() {
930 let (positions, indices) = unit_cube_mesh();
931 let mut mesh_lookup = HashMap::new();
932 mesh_lookup.insert(1u64, (positions, indices));
933
934 let obj = TestObject {
935 id: 42,
936 mesh_id: 1,
937 position: glam::Vec3::ZERO,
938 visible: true,
939 };
940 let objects: Vec<&dyn ViewportObject> = vec![&obj];
941
942 let result = pick_scene_with_probe(
944 glam::Vec3::new(0.0, 0.0, 5.0),
945 glam::Vec3::new(0.0, 0.0, -1.0),
946 &objects,
947 &mesh_lookup,
948 &[],
949 );
950 assert!(result.is_some());
951 assert!(result.unwrap().scalar_value.is_none());
952 }
953
954 fn make_view_proj() -> glam::Mat4 {
960 let view = glam::Mat4::look_at_rh(
961 glam::Vec3::new(0.0, 0.0, 5.0),
962 glam::Vec3::ZERO,
963 glam::Vec3::Y,
964 );
965 let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
966 proj * view
967 }
968
969 #[test]
970 fn test_pick_rect_mesh_full_screen() {
971 let (positions, indices) = unit_cube_mesh();
973 let mut mesh_lookup: std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)> =
974 std::collections::HashMap::new();
975 mesh_lookup.insert(0, (positions, indices.clone()));
976
977 let item = crate::renderer::SceneRenderItem {
978 mesh_index: 0,
979 model: glam::Mat4::IDENTITY.to_cols_array_2d(),
980 visible: true,
981 ..Default::default()
982 };
983
984 let view_proj = make_view_proj();
985 let viewport = glam::Vec2::new(800.0, 600.0);
986
987 let result = pick_rect(
988 glam::Vec2::ZERO,
989 viewport,
990 &[item],
991 &mesh_lookup,
992 &[],
993 view_proj,
994 viewport,
995 );
996
997 assert!(!result.is_empty(), "expected at least one triangle hit");
999 assert!(result.total_count() > 0);
1000 }
1001
1002 #[test]
1003 fn test_pick_rect_miss() {
1004 let (positions, indices) = unit_cube_mesh();
1006 let mut mesh_lookup: std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)> =
1007 std::collections::HashMap::new();
1008 mesh_lookup.insert(0, (positions, indices));
1009
1010 let item = crate::renderer::SceneRenderItem {
1011 mesh_index: 0,
1012 model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1013 visible: true,
1014 ..Default::default()
1015 };
1016
1017 let view_proj = make_view_proj();
1018 let viewport = glam::Vec2::new(800.0, 600.0);
1019
1020 let result = pick_rect(
1021 glam::Vec2::new(700.0, 500.0), glam::Vec2::new(799.0, 599.0),
1023 &[item],
1024 &mesh_lookup,
1025 &[],
1026 view_proj,
1027 viewport,
1028 );
1029
1030 assert!(result.is_empty(), "expected no hits in off-center rect");
1031 }
1032
1033 #[test]
1034 fn test_pick_rect_point_cloud() {
1035 let view_proj = make_view_proj();
1037 let viewport = glam::Vec2::new(800.0, 600.0);
1038
1039 let pc = crate::renderer::PointCloudItem {
1040 positions: vec![[0.0, 0.0, 0.0], [0.1, 0.1, 0.0]],
1041 model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1042 id: 99,
1043 ..Default::default()
1044 };
1045
1046 let result = pick_rect(
1047 glam::Vec2::ZERO,
1048 viewport,
1049 &[],
1050 &std::collections::HashMap::new(),
1051 &[pc],
1052 view_proj,
1053 viewport,
1054 );
1055
1056 assert!(!result.is_empty(), "expected point cloud hits");
1057 let hits = result.hits.get(&99).expect("expected hits for id 99");
1058 assert_eq!(
1059 hits.len(),
1060 2,
1061 "both points should be inside the full-screen rect"
1062 );
1063 }
1064
1065 #[test]
1066 fn test_pick_rect_skips_invisible() {
1067 let (positions, indices) = unit_cube_mesh();
1068 let mut mesh_lookup: std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)> =
1069 std::collections::HashMap::new();
1070 mesh_lookup.insert(0, (positions, indices));
1071
1072 let item = crate::renderer::SceneRenderItem {
1073 mesh_index: 0,
1074 model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1075 visible: false, ..Default::default()
1077 };
1078
1079 let view_proj = make_view_proj();
1080 let viewport = glam::Vec2::new(800.0, 600.0);
1081
1082 let result = pick_rect(
1083 glam::Vec2::ZERO,
1084 viewport,
1085 &[item],
1086 &mesh_lookup,
1087 &[],
1088 view_proj,
1089 viewport,
1090 );
1091
1092 assert!(result.is_empty(), "invisible items should be skipped");
1093 }
1094
1095 #[test]
1096 fn test_pick_rect_result_type() {
1097 let mut r = RectPickResult::default();
1099 assert!(r.is_empty());
1100 assert_eq!(r.total_count(), 0);
1101
1102 r.hits.insert(1, vec![0, 1, 2]);
1103 r.hits.insert(2, vec![5]);
1104 assert!(!r.is_empty());
1105 assert_eq!(r.total_count(), 4);
1106 }
1107
1108 #[test]
1109 fn test_barycentric_at_vertices() {
1110 let a = glam::Vec3::new(0.0, 0.0, 0.0);
1111 let b = glam::Vec3::new(1.0, 0.0, 0.0);
1112 let c = glam::Vec3::new(0.0, 1.0, 0.0);
1113
1114 let (u, v, w) = super::barycentric(a, a, b, c);
1116 assert!((u - 1.0).abs() < 1e-5, "u={u}");
1117 assert!(v.abs() < 1e-5, "v={v}");
1118 assert!(w.abs() < 1e-5, "w={w}");
1119
1120 let (u, v, w) = super::barycentric(b, a, b, c);
1122 assert!(u.abs() < 1e-5, "u={u}");
1123 assert!((v - 1.0).abs() < 1e-5, "v={v}");
1124 assert!(w.abs() < 1e-5, "w={w}");
1125
1126 let centroid = (a + b + c) / 3.0;
1128 let (u, v, w) = super::barycentric(centroid, a, b, c);
1129 assert!((u - 1.0 / 3.0).abs() < 1e-4, "u={u}");
1130 assert!((v - 1.0 / 3.0).abs() < 1e-4, "v={v}");
1131 assert!((w - 1.0 / 3.0).abs() < 1e-4, "w={w}");
1132 }
1133}