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