1use crate::geometry::marching_cubes::VolumeData;
6use crate::interaction::sub_object::SubObjectRef;
7use crate::resources::volume_mesh::{CELL_SENTINEL, VolumeMeshData};
8use crate::resources::{AttributeData, AttributeKind, AttributeRef};
9use crate::scene::traits::ViewportObject;
10use parry3d::math::{Pose, Vector};
11use parry3d::query::{Ray, RayCast};
12
13#[derive(Clone, Copy, Debug)]
22#[non_exhaustive]
23pub struct PickHit {
24 pub id: u64,
26 pub sub_object: Option<SubObjectRef>,
31 pub world_pos: glam::Vec3,
33 pub normal: glam::Vec3,
35 #[deprecated(since = "0.5.0", note = "use `sub_object` instead")]
40 pub triangle_index: u32,
41 #[deprecated(since = "0.5.0", note = "use `sub_object` instead")]
46 pub point_index: Option<u32>,
47 pub scalar_value: Option<f32>,
54}
55
56impl PickHit {
57 #[allow(deprecated)]
60 pub fn object_hit(id: u64, world_pos: glam::Vec3, normal: glam::Vec3) -> Self {
61 Self {
62 id,
63 sub_object: None,
64 world_pos,
65 normal,
66 triangle_index: u32::MAX,
67 point_index: None,
68 scalar_value: None,
69 }
70 }
71}
72
73#[derive(Clone, Copy, Debug)]
85#[non_exhaustive]
86pub struct GpuPickHit {
87 pub object_id: crate::renderer::PickId,
94 pub depth: f32,
103}
104
105pub fn screen_to_ray(
118 screen_pos: glam::Vec2,
119 viewport_size: glam::Vec2,
120 view_proj_inv: glam::Mat4,
121) -> (glam::Vec3, glam::Vec3) {
122 let ndc_x = (screen_pos.x / viewport_size.x) * 2.0 - 1.0;
123 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));
125 let far = view_proj_inv.project_point3(glam::Vec3::new(ndc_x, ndc_y, 1.0));
126 let dir = (far - near).normalize();
127 (near, dir)
128}
129
130pub fn pick_scene_cpu(
139 ray_origin: glam::Vec3,
140 ray_dir: glam::Vec3,
141 objects: &[&dyn ViewportObject],
142 mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
143) -> Option<PickHit> {
144 let ray = Ray::new(
146 Vector::new(ray_origin.x, ray_origin.y, ray_origin.z),
147 Vector::new(ray_dir.x, ray_dir.y, ray_dir.z),
148 );
149
150 let mut best_hit: Option<(u64, f32, PickHit)> = None;
151
152 for obj in objects {
153 if !obj.is_visible() {
154 continue;
155 }
156 let Some(mesh_id) = obj.mesh_id() else {
157 continue;
158 };
159
160 if let Some((positions, indices)) = mesh_lookup.get(&mesh_id) {
161 let s = obj.scale();
166 let verts: Vec<Vector> = positions
167 .iter()
168 .map(|p: &[f32; 3]| Vector::new(p[0] * s.x, p[1] * s.y, p[2] * s.z))
169 .collect();
170
171 let tri_indices: Vec<[u32; 3]> = indices
172 .chunks(3)
173 .filter(|c: &&[u32]| c.len() == 3)
174 .map(|c: &[u32]| [c[0], c[1], c[2]])
175 .collect();
176
177 if tri_indices.is_empty() {
178 continue;
179 }
180
181 match parry3d::shape::TriMesh::new(verts, tri_indices) {
182 Ok(trimesh) => {
183 let pose = Pose::from_parts(obj.position(), obj.rotation());
187 if let Some(intersection) =
188 trimesh.cast_ray_and_get_normal(&pose, &ray, f32::MAX, true)
189 {
190 let toi = intersection.time_of_impact;
191 if best_hit.is_none() || toi < best_hit.as_ref().unwrap().1 {
192 let sub_object = SubObjectRef::from_feature_id(intersection.feature);
193 let world_pos = ray_origin + ray_dir * toi;
194 let normal = intersection.normal;
196 let triangle_index = if let Some(SubObjectRef::Face(i)) = sub_object {
197 i
198 } else {
199 u32::MAX
200 };
201 #[allow(deprecated)]
202 let hit = PickHit {
203 id: obj.id(),
204 sub_object,
205 triangle_index,
206 world_pos,
207 normal,
208 point_index: None,
209 scalar_value: None,
210 };
211 best_hit = Some((obj.id(), toi, hit));
212 }
213 }
214 }
215 Err(e) => {
216 tracing::warn!(object_id = obj.id(), error = %e, "TriMesh construction failed for picking");
217 }
218 }
219 }
220 }
221
222 best_hit.map(|(_, _, hit)| hit)
223}
224
225pub fn pick_scene_nodes_cpu(
230 ray_origin: glam::Vec3,
231 ray_dir: glam::Vec3,
232 scene: &crate::scene::scene::Scene,
233 mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
234) -> Option<PickHit> {
235 let nodes: Vec<&dyn ViewportObject> = scene.nodes().map(|n| n as &dyn ViewportObject).collect();
236 pick_scene_cpu(ray_origin, ray_dir, &nodes, mesh_lookup)
237}
238
239pub struct ProbeBinding<'a> {
248 pub id: u64,
250 pub attribute_ref: &'a AttributeRef,
252 pub attribute_data: &'a AttributeData,
254 pub positions: &'a [[f32; 3]],
256 pub indices: &'a [u32],
258}
259
260fn barycentric(p: glam::Vec3, a: glam::Vec3, b: glam::Vec3, c: glam::Vec3) -> (f32, f32, f32) {
265 let v0 = b - a;
266 let v1 = c - a;
267 let v2 = p - a;
268 let d00 = v0.dot(v0);
269 let d01 = v0.dot(v1);
270 let d11 = v1.dot(v1);
271 let d20 = v2.dot(v0);
272 let d21 = v2.dot(v1);
273 let denom = d00 * d11 - d01 * d01;
274 if denom.abs() < 1e-12 {
275 return (1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0);
277 }
278 let inv = 1.0 / denom;
279 let v = (d11 * d20 - d01 * d21) * inv;
280 let w = (d00 * d21 - d01 * d20) * inv;
281 let u = 1.0 - v - w;
282 (u, v, w)
283}
284
285fn probe_scalar(hit: &mut PickHit, binding: &ProbeBinding<'_>) {
288 let tri_idx_raw = match hit.sub_object {
289 Some(SubObjectRef::Face(i)) => i,
290 _ => return,
291 };
292
293 let num_triangles = binding.indices.len() / 3;
294 let tri_idx = if (tri_idx_raw as usize) >= num_triangles && num_triangles > 0 {
297 tri_idx_raw as usize - num_triangles
298 } else {
299 tri_idx_raw as usize
300 };
301
302 match binding.attribute_ref.kind {
303 AttributeKind::Cell => {
304 if let AttributeData::Cell(data) = binding.attribute_data {
306 if let Some(&val) = data.get(tri_idx) {
307 hit.scalar_value = Some(val);
308 }
309 }
310 }
311 AttributeKind::Face => {
312 if let AttributeData::Face(data) = binding.attribute_data {
314 if let Some(&val) = data.get(tri_idx) {
315 hit.scalar_value = Some(val);
316 }
317 }
318 }
319 AttributeKind::FaceColour => {
320 }
322 AttributeKind::Vertex => {
323 if let AttributeData::Vertex(data) = binding.attribute_data {
325 let base = tri_idx * 3;
326 if base + 2 >= binding.indices.len() {
327 return;
328 }
329 let i0 = binding.indices[base] as usize;
330 let i1 = binding.indices[base + 1] as usize;
331 let i2 = binding.indices[base + 2] as usize;
332
333 if i0 >= data.len() || i1 >= data.len() || i2 >= data.len() {
334 return;
335 }
336 if i0 >= binding.positions.len()
337 || i1 >= binding.positions.len()
338 || i2 >= binding.positions.len()
339 {
340 return;
341 }
342
343 let a = glam::Vec3::from(binding.positions[i0]);
344 let b = glam::Vec3::from(binding.positions[i1]);
345 let c = glam::Vec3::from(binding.positions[i2]);
346 let (u, v, w) = barycentric(hit.world_pos, a, b, c);
347 hit.scalar_value = Some(data[i0] * u + data[i1] * v + data[i2] * w);
348 }
349 }
350 AttributeKind::Edge => {
351 if let AttributeData::Edge(data) = binding.attribute_data {
354 let base = tri_idx * 3;
355 if base + 2 >= binding.indices.len() || data.is_empty() {
356 return;
357 }
358 let i0 = binding.indices[base] as usize;
359 let i1 = binding.indices[base + 1] as usize;
360 let i2 = binding.indices[base + 2] as usize;
361 if i0 < data.len() || i1 < data.len() || i2 < data.len() {
362 if i0 < data.len()
364 && i1 < data.len()
365 && i2 < data.len()
366 && i0 < binding.positions.len()
367 && i1 < binding.positions.len()
368 && i2 < binding.positions.len()
369 {
370 let a = glam::Vec3::from(binding.positions[i0]);
371 let b = glam::Vec3::from(binding.positions[i1]);
372 let c = glam::Vec3::from(binding.positions[i2]);
373 let (u, v, w) = barycentric(hit.world_pos, a, b, c);
374 hit.scalar_value = Some(data[i0] * u + data[i1] * v + data[i2] * w);
375 }
376 }
377 }
378 }
379 AttributeKind::Halfedge | AttributeKind::Corner => {
380 let extract = |data: &[f32]| -> Option<f32> {
383 let base = tri_idx * 3;
384 if base + 2 >= data.len() {
385 return None;
386 }
387 Some(data[base])
389 };
390 match binding.attribute_data {
391 AttributeData::Halfedge(data) | AttributeData::Corner(data) => {
392 hit.scalar_value = extract(data);
393 }
394 _ => {}
395 }
396 }
397 }
398}
399
400pub fn pick_scene_with_probe_cpu(
407 ray_origin: glam::Vec3,
408 ray_dir: glam::Vec3,
409 objects: &[&dyn ViewportObject],
410 mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
411 probe_bindings: &[ProbeBinding<'_>],
412) -> Option<PickHit> {
413 let mut hit = pick_scene_cpu(ray_origin, ray_dir, objects, mesh_lookup)?;
414 if let Some(binding) = probe_bindings.iter().find(|b| b.id == hit.id) {
415 probe_scalar(&mut hit, binding);
416 }
417 Some(hit)
418}
419
420pub fn pick_scene_nodes_with_probe_cpu(
424 ray_origin: glam::Vec3,
425 ray_dir: glam::Vec3,
426 scene: &crate::scene::scene::Scene,
427 mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
428 probe_bindings: &[ProbeBinding<'_>],
429) -> Option<PickHit> {
430 let mut hit = pick_scene_nodes_cpu(ray_origin, ray_dir, scene, mesh_lookup)?;
431 if let Some(binding) = probe_bindings.iter().find(|b| b.id == hit.id) {
432 probe_scalar(&mut hit, binding);
433 }
434 Some(hit)
435}
436
437pub fn pick_scene_accelerated_with_probe_cpu(
442 ray_origin: glam::Vec3,
443 ray_dir: glam::Vec3,
444 accelerator: &mut crate::geometry::bvh::PickAccelerator,
445 mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
446 probe_bindings: &[ProbeBinding<'_>],
447) -> Option<PickHit> {
448 let mut hit = accelerator.pick(ray_origin, ray_dir, mesh_lookup)?;
449 if let Some(binding) = probe_bindings.iter().find(|b| b.id == hit.id) {
450 probe_scalar(&mut hit, binding);
451 }
452 Some(hit)
453}
454
455#[derive(Clone, Debug, Default)]
466pub struct RectPickResult {
467 pub hits: std::collections::HashMap<u64, Vec<SubObjectRef>>,
474}
475
476impl RectPickResult {
477 pub fn is_empty(&self) -> bool {
479 self.hits.is_empty()
480 }
481
482 pub fn total_count(&self) -> usize {
484 self.hits.values().map(|v| v.len()).sum()
485 }
486}
487
488pub fn pick_rect(
506 rect_min: glam::Vec2,
507 rect_max: glam::Vec2,
508 scene_items: &[crate::renderer::SceneRenderItem],
509 mesh_lookup: &std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)>,
510 point_clouds: &[crate::renderer::PointCloudItem],
511 view_proj: glam::Mat4,
512 viewport_size: glam::Vec2,
513) -> RectPickResult {
514 let ndc_min = glam::Vec2::new(
517 rect_min.x / viewport_size.x * 2.0 - 1.0,
518 1.0 - rect_max.y / viewport_size.y * 2.0, );
520 let ndc_max = glam::Vec2::new(
521 rect_max.x / viewport_size.x * 2.0 - 1.0,
522 1.0 - rect_min.y / viewport_size.y * 2.0, );
524
525 let mut result = RectPickResult::default();
526
527 for item in scene_items {
529 if item.appearance.hidden {
530 continue;
531 }
532 let Some((positions, indices)) = mesh_lookup.get(&item.mesh_id.index()) else {
533 continue;
534 };
535
536 let model = glam::Mat4::from_cols_array_2d(&item.model);
537 let mvp = view_proj * model;
538
539 let mut tri_hits: Vec<SubObjectRef> = Vec::new();
540
541 for (tri_idx, chunk) in indices.chunks(3).enumerate() {
542 if chunk.len() < 3 {
543 continue;
544 }
545 let i0 = chunk[0] as usize;
546 let i1 = chunk[1] as usize;
547 let i2 = chunk[2] as usize;
548
549 if i0 >= positions.len() || i1 >= positions.len() || i2 >= positions.len() {
550 continue;
551 }
552
553 let p0 = glam::Vec3::from(positions[i0]);
554 let p1 = glam::Vec3::from(positions[i1]);
555 let p2 = glam::Vec3::from(positions[i2]);
556 let centroid = (p0 + p1 + p2) / 3.0;
557
558 let clip = mvp * centroid.extend(1.0);
559 if clip.w <= 0.0 {
560 continue;
562 }
563 let ndc = glam::Vec2::new(clip.x / clip.w, clip.y / clip.w);
564
565 if ndc.x >= ndc_min.x && ndc.x <= ndc_max.x && ndc.y >= ndc_min.y && ndc.y <= ndc_max.y
566 {
567 tri_hits.push(SubObjectRef::Face(tri_idx as u32));
568 }
569 }
570
571 if !tri_hits.is_empty() {
572 result.hits.insert(item.pick_id.0, tri_hits);
573 }
574 }
575
576 for pc in point_clouds {
578 if pc.id == 0 {
579 continue;
581 }
582
583 let model = glam::Mat4::from_cols_array_2d(&pc.model);
584 let mvp = view_proj * model;
585
586 let mut pt_hits: Vec<SubObjectRef> = Vec::new();
587
588 for (pt_idx, pos) in pc.positions.iter().enumerate() {
589 let p = glam::Vec3::from(*pos);
590 let clip = mvp * p.extend(1.0);
591 if clip.w <= 0.0 {
592 continue;
593 }
594 let ndc = glam::Vec2::new(clip.x / clip.w, clip.y / clip.w);
595
596 if ndc.x >= ndc_min.x && ndc.x <= ndc_max.x && ndc.y >= ndc_min.y && ndc.y <= ndc_max.y
597 {
598 pt_hits.push(SubObjectRef::Point(pt_idx as u32));
599 }
600 }
601
602 if !pt_hits.is_empty() {
603 result.hits.insert(pc.id, pt_hits);
604 }
605 }
606
607 result
608}
609
610pub fn box_select(
619 rect_min: glam::Vec2,
620 rect_max: glam::Vec2,
621 objects: &[&dyn ViewportObject],
622 view_proj: glam::Mat4,
623 viewport_size: glam::Vec2,
624) -> Vec<u64> {
625 let mut hits = Vec::new();
626 for obj in objects {
627 if !obj.is_visible() {
628 continue;
629 }
630 let pos = obj.position();
631 let clip = view_proj * pos.extend(1.0);
632 if clip.w <= 0.0 {
634 continue;
635 }
636 let ndc = glam::Vec3::new(clip.x / clip.w, clip.y / clip.w, clip.z / clip.w);
637 let screen = glam::Vec2::new(
638 (ndc.x + 1.0) * 0.5 * viewport_size.x,
639 (1.0 - ndc.y) * 0.5 * viewport_size.y,
640 );
641 if screen.x >= rect_min.x
642 && screen.x <= rect_max.x
643 && screen.y >= rect_min.y
644 && screen.y <= rect_max.y
645 {
646 hits.push(obj.id());
647 }
648 }
649 hits
650}
651
652fn ray_aabb_volume(
663 origin: glam::Vec3,
664 dir: glam::Vec3,
665 bbox_min: glam::Vec3,
666 bbox_max: glam::Vec3,
667) -> Option<(f32, f32, usize, f32)> {
668 let mut t_min = f32::NEG_INFINITY;
669 let mut t_max = f32::INFINITY;
670 let mut entry_axis = 0usize;
671 let mut entry_sign = -1.0f32;
672
673 let dirs = [dir.x, dir.y, dir.z];
674 let origins = [origin.x, origin.y, origin.z];
675 let mins = [bbox_min.x, bbox_min.y, bbox_min.z];
676 let maxs = [bbox_max.x, bbox_max.y, bbox_max.z];
677
678 for i in 0..3 {
679 let d = dirs[i];
680 let o = origins[i];
681 if d.abs() < 1e-12 {
682 if o < mins[i] || o > maxs[i] {
684 return None;
685 }
686 } else {
687 let t1 = (mins[i] - o) / d;
688 let t2 = (maxs[i] - o) / d;
689 let (t_near, t_far) = if t1 <= t2 { (t1, t2) } else { (t2, t1) };
690 if t_near > t_min {
691 t_min = t_near;
692 entry_axis = i;
693 entry_sign = if d > 0.0 { -1.0 } else { 1.0 };
696 }
697 if t_far < t_max {
698 t_max = t_far;
699 }
700 }
701 }
702
703 if t_min > t_max || t_max < 0.0 {
704 return None;
705 }
706 Some((t_min, t_max, entry_axis, entry_sign))
707}
708
709pub fn pick_volume_cpu(
734 ray_origin: glam::Vec3,
735 ray_dir: glam::Vec3,
736 id: u64,
737 item: &crate::renderer::VolumeItem,
738 volume: &VolumeData,
739) -> Option<PickHit> {
740 let [nx, ny, nz] = volume.dims;
741 if nx == 0 || ny == 0 || nz == 0 || volume.data.is_empty() {
742 return None;
743 }
744
745 let model = glam::Mat4::from_cols_array_2d(&item.model);
747 let inv_model = model.inverse();
748 let local_origin = inv_model.transform_point3(ray_origin);
749 let local_dir = inv_model.transform_vector3(ray_dir);
750
751 let bbox_min = glam::Vec3::from(item.bbox_min);
752 let bbox_max = glam::Vec3::from(item.bbox_max);
753
754 let (t_entry, t_exit, entry_axis, entry_sign) =
755 ray_aabb_volume(local_origin, local_dir, bbox_min, bbox_max)?;
756
757 let t_start = t_entry.max(0.0);
759 if t_start >= t_exit {
760 return None;
761 }
762
763 let extent = bbox_max - bbox_min;
765 let cell = extent / glam::Vec3::new(nx as f32, ny as f32, nz as f32);
766
767 let p_entry = local_origin + t_start * local_dir;
769
770 let eps = 1e-4_f32;
773 let frac =
774 ((p_entry - bbox_min) / extent).clamp(glam::Vec3::splat(eps), glam::Vec3::splat(1.0 - eps));
775 let mut ix = (frac.x * nx as f32).floor() as i32;
776 let mut iy = (frac.y * ny as f32).floor() as i32;
777 let mut iz = (frac.z * nz as f32).floor() as i32;
778 ix = ix.clamp(0, nx as i32 - 1);
779 iy = iy.clamp(0, ny as i32 - 1);
780 iz = iz.clamp(0, nz as i32 - 1);
781
782 let step_x: i32 = if local_dir.x >= 0.0 { 1 } else { -1 };
784 let step_y: i32 = if local_dir.y >= 0.0 { 1 } else { -1 };
785 let step_z: i32 = if local_dir.z >= 0.0 { 1 } else { -1 };
786
787 let td_x = if local_dir.x.abs() > 1e-12 {
789 cell.x / local_dir.x.abs()
790 } else {
791 f32::INFINITY
792 };
793 let td_y = if local_dir.y.abs() > 1e-12 {
794 cell.y / local_dir.y.abs()
795 } else {
796 f32::INFINITY
797 };
798 let td_z = if local_dir.z.abs() > 1e-12 {
799 cell.z / local_dir.z.abs()
800 } else {
801 f32::INFINITY
802 };
803
804 let next_bx = bbox_min.x + (if step_x > 0 { ix + 1 } else { ix }) as f32 * cell.x;
806 let next_by = bbox_min.y + (if step_y > 0 { iy + 1 } else { iy }) as f32 * cell.y;
807 let next_bz = bbox_min.z + (if step_z > 0 { iz + 1 } else { iz }) as f32 * cell.z;
808
809 let mut tmax_x = if local_dir.x.abs() > 1e-12 {
810 t_start + (next_bx - p_entry.x) / local_dir.x
811 } else {
812 f32::INFINITY
813 };
814 let mut tmax_y = if local_dir.y.abs() > 1e-12 {
815 t_start + (next_by - p_entry.y) / local_dir.y
816 } else {
817 f32::INFINITY
818 };
819 let mut tmax_z = if local_dir.z.abs() > 1e-12 {
820 t_start + (next_bz - p_entry.z) / local_dir.z
821 } else {
822 f32::INFINITY
823 };
824
825 let mut entry_normal_local = glam::Vec3::ZERO;
827 match entry_axis {
828 0 => entry_normal_local.x = entry_sign,
829 1 => entry_normal_local.y = entry_sign,
830 _ => entry_normal_local.z = entry_sign,
831 }
832
833 let mut t_voxel_entry = t_start;
835
836 loop {
837 if ix < 0 || ix >= nx as i32 || iy < 0 || iy >= ny as i32 || iz < 0 || iz >= nz as i32 {
839 break;
840 }
841
842 let flat = ix as u32 + iy as u32 * nx + iz as u32 * nx * ny;
843 let scalar = volume.data[flat as usize];
844
845 if !scalar.is_nan() && scalar >= item.threshold_min && scalar <= item.threshold_max {
847 let local_hit = local_origin + t_voxel_entry * local_dir;
848 let world_pos = model.transform_point3(local_hit);
849 let world_normal = inv_model
851 .transpose()
852 .transform_vector3(entry_normal_local)
853 .normalize();
854
855 #[allow(deprecated)]
856 return Some(PickHit {
857 id,
858 sub_object: Some(SubObjectRef::Voxel(flat)),
859 world_pos,
860 normal: world_normal,
861 triangle_index: u32::MAX,
862 point_index: None,
863 scalar_value: Some(scalar),
864 });
865 }
866
867 if tmax_x <= tmax_y && tmax_x <= tmax_z {
869 if tmax_x > t_exit {
870 break;
871 }
872 t_voxel_entry = tmax_x;
873 tmax_x += td_x;
874 ix += step_x;
875 entry_normal_local = glam::Vec3::new(-(step_x as f32), 0.0, 0.0);
876 } else if tmax_y <= tmax_z {
877 if tmax_y > t_exit {
878 break;
879 }
880 t_voxel_entry = tmax_y;
881 tmax_y += td_y;
882 iy += step_y;
883 entry_normal_local = glam::Vec3::new(0.0, -(step_y as f32), 0.0);
884 } else {
885 if tmax_z > t_exit {
886 break;
887 }
888 t_voxel_entry = tmax_z;
889 tmax_z += td_z;
890 iz += step_z;
891 entry_normal_local = glam::Vec3::new(0.0, 0.0, -(step_z as f32));
892 }
893 }
894
895 None
896}
897
898pub fn voxel_world_aabb(
912 flat_index: u32,
913 volume: &VolumeData,
914 item: &crate::renderer::VolumeItem,
915) -> (glam::Vec3, glam::Vec3) {
916 let [nx, ny, nz] = volume.dims;
917 let ix = flat_index % nx;
918 let iy = (flat_index / nx) % ny;
919 let iz = flat_index / (nx * ny);
920 assert!(
921 ix < nx && iy < ny && iz < nz,
922 "flat_index {} out of bounds for dims {:?}",
923 flat_index,
924 volume.dims
925 );
926
927 let bbox_min = glam::Vec3::from(item.bbox_min);
928 let bbox_max = glam::Vec3::from(item.bbox_max);
929 let cell = (bbox_max - bbox_min) / glam::Vec3::new(nx as f32, ny as f32, nz as f32);
930
931 let local_lo =
932 bbox_min + glam::Vec3::new(ix as f32 * cell.x, iy as f32 * cell.y, iz as f32 * cell.z);
933 let local_hi = local_lo + cell;
934
935 let model = glam::Mat4::from_cols_array_2d(&item.model);
936 let corners = [
937 glam::Vec3::new(local_lo.x, local_lo.y, local_lo.z),
938 glam::Vec3::new(local_hi.x, local_lo.y, local_lo.z),
939 glam::Vec3::new(local_lo.x, local_hi.y, local_lo.z),
940 glam::Vec3::new(local_hi.x, local_hi.y, local_lo.z),
941 glam::Vec3::new(local_lo.x, local_lo.y, local_hi.z),
942 glam::Vec3::new(local_hi.x, local_lo.y, local_hi.z),
943 glam::Vec3::new(local_lo.x, local_hi.y, local_hi.z),
944 glam::Vec3::new(local_hi.x, local_hi.y, local_hi.z),
945 ];
946
947 let world_min = corners
948 .iter()
949 .map(|&c| model.transform_point3(c))
950 .fold(glam::Vec3::splat(f32::INFINITY), |acc, c| acc.min(c));
951 let world_max = corners
952 .iter()
953 .map(|&c| model.transform_point3(c))
954 .fold(glam::Vec3::splat(f32::NEG_INFINITY), |acc, c| acc.max(c));
955
956 (world_min, world_max)
957}
958
959pub fn pick_point_cloud_cpu(
973 click_pos: glam::Vec2,
974 id: u64,
975 item: &crate::renderer::PointCloudItem,
976 view_proj: glam::Mat4,
977 viewport_size: glam::Vec2,
978 radius_px: f32,
979) -> Option<PickHit> {
980 if id == 0 || item.positions.is_empty() {
981 return None;
982 }
983
984 let model = glam::Mat4::from_cols_array_2d(&item.model);
985 let mvp = view_proj * model;
986
987 let mut best_dist_sq = radius_px * radius_px;
988 let mut best_idx: Option<u32> = None;
989 let mut best_world = glam::Vec3::ZERO;
990
991 for (pt_idx, pos) in item.positions.iter().enumerate() {
992 let local = glam::Vec3::from(*pos);
993 let clip = mvp * local.extend(1.0);
994 if clip.w <= 0.0 {
995 continue;
996 }
997 let ndc_x = clip.x / clip.w;
998 let ndc_y = clip.y / clip.w;
999 let sx = (ndc_x + 1.0) * 0.5 * viewport_size.x;
1000 let sy = (1.0 - ndc_y) * 0.5 * viewport_size.y;
1001 let dx = sx - click_pos.x;
1002 let dy = sy - click_pos.y;
1003 let dist_sq = dx * dx + dy * dy;
1004 if dist_sq < best_dist_sq {
1005 best_dist_sq = dist_sq;
1006 best_idx = Some(pt_idx as u32);
1007 best_world = model.transform_point3(local);
1008 }
1009 }
1010
1011 let pt_idx = best_idx?;
1012 #[allow(deprecated)]
1013 Some(PickHit {
1014 id,
1015 sub_object: Some(SubObjectRef::Point(pt_idx)),
1016 world_pos: best_world,
1017 normal: glam::Vec3::Y,
1018 triangle_index: u32::MAX,
1019 point_index: Some(pt_idx),
1020 scalar_value: None,
1021 })
1022}
1023
1024pub fn nearest_vertex_on_hit(
1043 hit: &PickHit,
1044 positions: &[[f32; 3]],
1045 indices: &[u32],
1046 model: glam::Mat4,
1047) -> Option<SubObjectRef> {
1048 let face_raw = match hit.sub_object {
1049 Some(SubObjectRef::Face(i)) => i as usize,
1050 _ => return None,
1051 };
1052 let n_tri = indices.len() / 3;
1053 if n_tri == 0 {
1054 return None;
1055 }
1056 let face = if face_raw >= n_tri {
1058 face_raw - n_tri
1059 } else {
1060 face_raw
1061 };
1062 if face * 3 + 2 >= indices.len() {
1063 return None;
1064 }
1065 let vi = [
1066 indices[face * 3] as usize,
1067 indices[face * 3 + 1] as usize,
1068 indices[face * 3 + 2] as usize,
1069 ];
1070 let (best_vi, _) = vi
1071 .iter()
1072 .map(|&i| {
1073 let p = model.transform_point3(glam::Vec3::from(positions[i]));
1074 (i, p.distance(hit.world_pos))
1075 })
1076 .fold(
1077 (vi[0], f32::MAX),
1078 |acc, (i, d)| {
1079 if d < acc.1 { (i, d) } else { acc }
1080 },
1081 );
1082 Some(SubObjectRef::Vertex(best_vi as u32))
1083}
1084
1085pub fn pick_gaussian_splat_cpu(
1106 click_pos: glam::Vec2,
1107 id: u64,
1108 positions: &[[f32; 3]],
1109 model: glam::Mat4,
1110 view_proj: glam::Mat4,
1111 viewport_size: glam::Vec2,
1112 radius_px: f32,
1113) -> Option<PickHit> {
1114 if id == 0 || positions.is_empty() {
1115 return None;
1116 }
1117 let mvp = view_proj * model;
1118 let mut best_dist_sq = radius_px * radius_px;
1119 let mut best_idx: Option<u32> = None;
1120 let mut best_world = glam::Vec3::ZERO;
1121
1122 for (i, pos) in positions.iter().enumerate() {
1123 let local = glam::Vec3::from(*pos);
1124 let clip = mvp * local.extend(1.0);
1125 if clip.w <= 0.0 {
1126 continue;
1127 }
1128 let sx = (clip.x / clip.w + 1.0) * 0.5 * viewport_size.x;
1129 let sy = (1.0 - clip.y / clip.w) * 0.5 * viewport_size.y;
1130 let dx = sx - click_pos.x;
1131 let dy = sy - click_pos.y;
1132 let dist_sq = dx * dx + dy * dy;
1133 if dist_sq < best_dist_sq {
1134 best_dist_sq = dist_sq;
1135 best_idx = Some(i as u32);
1136 best_world = model.transform_point3(local);
1137 }
1138 }
1139
1140 let idx = best_idx?;
1141 #[allow(deprecated)]
1142 Some(PickHit {
1143 id,
1144 sub_object: Some(SubObjectRef::Point(idx)),
1145 world_pos: best_world,
1146 normal: glam::Vec3::Y,
1147 triangle_index: u32::MAX,
1148 point_index: Some(idx),
1149 scalar_value: None,
1150 })
1151}
1152
1153fn ray_tri_mt_ds(
1161 orig: glam::Vec3,
1162 dir: glam::Vec3,
1163 v0: glam::Vec3,
1164 v1: glam::Vec3,
1165 v2: glam::Vec3,
1166) -> Option<f32> {
1167 let e1 = v1 - v0;
1168 let e2 = v2 - v0;
1169 let h = dir.cross(e2);
1170 let a = e1.dot(h);
1171 if a.abs() < 1e-8 {
1172 return None;
1173 }
1174 let f = 1.0 / a;
1175 let s = orig - v0;
1176 let u = f * s.dot(h);
1177 if !(0.0..=1.0).contains(&u) {
1178 return None;
1179 }
1180 let q = s.cross(e1);
1181 let v = f * dir.dot(q);
1182 if v < 0.0 || u + v > 1.0 {
1183 return None;
1184 }
1185 let t = f * e2.dot(q);
1186 if t > 1e-6 { Some(t) } else { None }
1187}
1188
1189const VM_TET_FACES: [[usize; 3]; 4] = [[1, 2, 3], [0, 3, 2], [0, 1, 3], [0, 2, 1]];
1194
1195const VM_HEX_TRIS: [[usize; 3]; 12] = [
1197 [0, 1, 2],
1198 [0, 2, 3], [4, 7, 6],
1200 [4, 6, 5], [0, 4, 5],
1202 [0, 5, 1], [2, 6, 7],
1204 [2, 7, 3], [0, 3, 7],
1206 [0, 7, 4], [1, 5, 6],
1208 [1, 6, 2], ];
1210
1211const VM_PYRAMID_TRIS: [[usize; 3]; 6] = [
1213 [0, 1, 2],
1214 [0, 2, 3], [0, 4, 1],
1216 [1, 4, 2],
1217 [2, 4, 3],
1218 [3, 4, 0], ];
1220
1221const VM_WEDGE_TRIS: [[usize; 3]; 8] = [
1223 [0, 2, 1],
1224 [3, 4, 5], [0, 1, 4],
1226 [0, 4, 3], [1, 2, 5],
1228 [1, 5, 4], [2, 0, 3],
1230 [2, 3, 5], ];
1232
1233pub fn pick_transparent_volume_mesh_cpu(
1248 ray_origin: glam::Vec3,
1249 ray_dir: glam::Vec3,
1250 id: u64,
1251 model: glam::Mat4,
1252 data: &VolumeMeshData,
1253) -> Option<PickHit> {
1254 if id == 0 || data.cells.is_empty() {
1255 return None;
1256 }
1257 let model_inv = model.inverse();
1258 let local_origin = model_inv.transform_point3(ray_origin);
1259 let local_dir = model_inv.transform_vector3(ray_dir);
1260
1261 let mut best_t = f32::MAX;
1262 let mut best_cell: Option<u32> = None;
1263
1264 for (cell_idx, cell) in data.cells.iter().enumerate() {
1265 let p = |i: usize| glam::Vec3::from(data.positions[cell[i] as usize]);
1266 let tris: &[[usize; 3]] = if cell[4] == CELL_SENTINEL {
1267 &VM_TET_FACES
1268 } else if cell[5] == CELL_SENTINEL {
1269 &VM_PYRAMID_TRIS
1270 } else if cell[6] == CELL_SENTINEL {
1271 &VM_WEDGE_TRIS
1272 } else {
1273 &VM_HEX_TRIS
1274 };
1275 for tri in tris {
1276 if let Some(t) = ray_tri_mt_ds(local_origin, local_dir, p(tri[0]), p(tri[1]), p(tri[2]))
1277 {
1278 if t < best_t {
1279 best_t = t;
1280 best_cell = Some(cell_idx as u32);
1281 }
1282 }
1283 }
1284 }
1285
1286 let cell_idx = best_cell?;
1287 let local_hit = local_origin + local_dir * best_t;
1288 let world_hit = model.transform_point3(local_hit);
1289 #[allow(deprecated)]
1290 Some(PickHit {
1291 id,
1292 sub_object: Some(SubObjectRef::Cell(cell_idx)),
1293 world_pos: world_hit,
1294 normal: -ray_dir.normalize(),
1295 triangle_index: u32::MAX,
1296 point_index: None,
1297 scalar_value: None,
1298 })
1299}
1300
1301pub fn pick_volume_rect(
1321 rect_min: glam::Vec2,
1322 rect_max: glam::Vec2,
1323 id: u64,
1324 item: &crate::renderer::VolumeItem,
1325 volume: &VolumeData,
1326 view_proj: glam::Mat4,
1327 viewport_size: glam::Vec2,
1328) -> RectPickResult {
1329 let mut result = RectPickResult::default();
1330 if id == 0 {
1331 return result;
1332 }
1333 let model = glam::Mat4::from_cols_array_2d(&item.model);
1334 let bbox_min = glam::Vec3::from(item.bbox_min);
1335 let bbox_max = glam::Vec3::from(item.bbox_max);
1336 let [nx, ny, nz] = volume.dims;
1337 let cell = (bbox_max - bbox_min) / glam::Vec3::new(nx as f32, ny as f32, nz as f32);
1338 let mvp = view_proj * model;
1339
1340 let mut hits: Vec<SubObjectRef> = Vec::new();
1341 for iz in 0..nz {
1342 for iy in 0..ny {
1343 for ix in 0..nx {
1344 let flat = ix + iy * nx + iz * nx * ny;
1345 let scalar = volume.data[flat as usize];
1346 if scalar.is_nan() || scalar < item.threshold_min || scalar > item.threshold_max {
1347 continue;
1348 }
1349 let local_center = bbox_min
1350 + cell * glam::Vec3::new(ix as f32 + 0.5, iy as f32 + 0.5, iz as f32 + 0.5);
1351 let clip = mvp * local_center.extend(1.0);
1352 if clip.w <= 0.0 {
1353 continue;
1354 }
1355 let sx = (clip.x / clip.w + 1.0) * 0.5 * viewport_size.x;
1356 let sy = (1.0 - clip.y / clip.w) * 0.5 * viewport_size.y;
1357 if sx >= rect_min.x && sx <= rect_max.x && sy >= rect_min.y && sy <= rect_max.y {
1358 hits.push(SubObjectRef::Voxel(flat));
1359 }
1360 }
1361 }
1362 }
1363 if !hits.is_empty() {
1364 result.hits.insert(id, hits);
1365 }
1366 result
1367}
1368
1369pub fn pick_transparent_volume_mesh_rect(
1388 rect_min: glam::Vec2,
1389 rect_max: glam::Vec2,
1390 id: u64,
1391 model: glam::Mat4,
1392 data: &VolumeMeshData,
1393 view_proj: glam::Mat4,
1394 viewport_size: glam::Vec2,
1395) -> RectPickResult {
1396 let mut result = RectPickResult::default();
1397 if id == 0 || data.cells.is_empty() {
1398 return result;
1399 }
1400 let mvp = view_proj * model;
1401 let mut hits: Vec<SubObjectRef> = Vec::new();
1402
1403 for (cell_idx, cell) in data.cells.iter().enumerate() {
1404 let nv: usize = if cell[4] == CELL_SENTINEL {
1405 4
1406 } else if cell[5] == CELL_SENTINEL {
1407 5
1408 } else if cell[6] == CELL_SENTINEL {
1409 6
1410 } else {
1411 8
1412 };
1413 let centroid: glam::Vec3 = cell[..nv]
1414 .iter()
1415 .map(|&vi| glam::Vec3::from(data.positions[vi as usize]))
1416 .sum::<glam::Vec3>()
1417 / nv as f32;
1418 let clip = mvp * centroid.extend(1.0);
1419 if clip.w <= 0.0 {
1420 continue;
1421 }
1422 let sx = (clip.x / clip.w + 1.0) * 0.5 * viewport_size.x;
1423 let sy = (1.0 - clip.y / clip.w) * 0.5 * viewport_size.y;
1424 if sx >= rect_min.x && sx <= rect_max.x && sy >= rect_min.y && sy <= rect_max.y {
1425 hits.push(SubObjectRef::Cell(cell_idx as u32));
1426 }
1427 }
1428 if !hits.is_empty() {
1429 result.hits.insert(id, hits);
1430 }
1431 result
1432}
1433
1434pub fn pick_gaussian_splat_rect(
1453 rect_min: glam::Vec2,
1454 rect_max: glam::Vec2,
1455 id: u64,
1456 positions: &[[f32; 3]],
1457 model: glam::Mat4,
1458 view_proj: glam::Mat4,
1459 viewport_size: glam::Vec2,
1460) -> RectPickResult {
1461 let mut result = RectPickResult::default();
1462 if id == 0 || positions.is_empty() {
1463 return result;
1464 }
1465 let mvp = view_proj * model;
1466 let mut hits: Vec<SubObjectRef> = Vec::new();
1467
1468 for (i, pos) in positions.iter().enumerate() {
1469 let local = glam::Vec3::from(*pos);
1470 let clip = mvp * local.extend(1.0);
1471 if clip.w <= 0.0 {
1472 continue;
1473 }
1474 let sx = (clip.x / clip.w + 1.0) * 0.5 * viewport_size.x;
1475 let sy = (1.0 - clip.y / clip.w) * 0.5 * viewport_size.y;
1476 if sx >= rect_min.x && sx <= rect_max.x && sy >= rect_min.y && sy <= rect_max.y {
1477 hits.push(SubObjectRef::Point(i as u32));
1478 }
1479 }
1480 if !hits.is_empty() {
1481 result.hits.insert(id, hits);
1482 }
1483 result
1484}
1485
1486#[cfg(test)]
1487mod tests {
1488 use super::*;
1489 use crate::scene::traits::ViewportObject;
1490 use std::collections::HashMap;
1491
1492 struct TestObject {
1493 id: u64,
1494 mesh_id: u64,
1495 position: glam::Vec3,
1496 visible: bool,
1497 }
1498
1499 impl ViewportObject for TestObject {
1500 fn id(&self) -> u64 {
1501 self.id
1502 }
1503 fn mesh_id(&self) -> Option<u64> {
1504 Some(self.mesh_id)
1505 }
1506 fn model_matrix(&self) -> glam::Mat4 {
1507 glam::Mat4::from_translation(self.position)
1508 }
1509 fn position(&self) -> glam::Vec3 {
1510 self.position
1511 }
1512 fn rotation(&self) -> glam::Quat {
1513 glam::Quat::IDENTITY
1514 }
1515 fn is_visible(&self) -> bool {
1516 self.visible
1517 }
1518 fn colour(&self) -> glam::Vec3 {
1519 glam::Vec3::ONE
1520 }
1521 }
1522
1523 fn unit_cube_mesh() -> (Vec<[f32; 3]>, Vec<u32>) {
1525 let positions = vec![
1526 [-0.5, -0.5, -0.5],
1527 [0.5, -0.5, -0.5],
1528 [0.5, 0.5, -0.5],
1529 [-0.5, 0.5, -0.5],
1530 [-0.5, -0.5, 0.5],
1531 [0.5, -0.5, 0.5],
1532 [0.5, 0.5, 0.5],
1533 [-0.5, 0.5, 0.5],
1534 ];
1535 let indices = vec![
1536 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, ];
1543 (positions, indices)
1544 }
1545
1546 #[test]
1547 fn test_screen_to_ray_center() {
1548 let vp_inv = glam::Mat4::IDENTITY;
1550 let (origin, dir) = screen_to_ray(
1551 glam::Vec2::new(400.0, 300.0),
1552 glam::Vec2::new(800.0, 600.0),
1553 vp_inv,
1554 );
1555 assert!(origin.x.abs() < 1e-3, "origin.x={}", origin.x);
1557 assert!(origin.y.abs() < 1e-3, "origin.y={}", origin.y);
1558 assert!(dir.z.abs() > 0.9, "dir should be along Z, got {dir:?}");
1559 }
1560
1561 #[test]
1562 fn test_pick_scene_hit() {
1563 let (positions, indices) = unit_cube_mesh();
1564 let mut mesh_lookup = HashMap::new();
1565 mesh_lookup.insert(1u64, (positions, indices));
1566
1567 let obj = TestObject {
1568 id: 42,
1569 mesh_id: 1,
1570 position: glam::Vec3::ZERO,
1571 visible: true,
1572 };
1573 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1574
1575 let result = pick_scene_cpu(
1577 glam::Vec3::new(0.0, 0.0, 5.0),
1578 glam::Vec3::new(0.0, 0.0, -1.0),
1579 &objects,
1580 &mesh_lookup,
1581 );
1582 assert!(result.is_some(), "expected a hit");
1583 let hit = result.unwrap();
1584 assert_eq!(hit.id, 42);
1585 assert!(
1587 (hit.world_pos.z - 0.5).abs() < 0.01,
1588 "world_pos.z={}",
1589 hit.world_pos.z
1590 );
1591 assert!(hit.normal.z > 0.9, "normal={:?}", hit.normal);
1593 }
1594
1595 #[test]
1596 fn test_pick_scene_miss() {
1597 let (positions, indices) = unit_cube_mesh();
1598 let mut mesh_lookup = HashMap::new();
1599 mesh_lookup.insert(1u64, (positions, indices));
1600
1601 let obj = TestObject {
1602 id: 42,
1603 mesh_id: 1,
1604 position: glam::Vec3::ZERO,
1605 visible: true,
1606 };
1607 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1608
1609 let result = pick_scene_cpu(
1611 glam::Vec3::new(100.0, 100.0, 5.0),
1612 glam::Vec3::new(0.0, 0.0, -1.0),
1613 &objects,
1614 &mesh_lookup,
1615 );
1616 assert!(result.is_none());
1617 }
1618
1619 #[test]
1620 fn test_pick_nearest_wins() {
1621 let (positions, indices) = unit_cube_mesh();
1622 let mut mesh_lookup = HashMap::new();
1623 mesh_lookup.insert(1u64, (positions.clone(), indices.clone()));
1624 mesh_lookup.insert(2u64, (positions, indices));
1625
1626 let near_obj = TestObject {
1627 id: 10,
1628 mesh_id: 1,
1629 position: glam::Vec3::new(0.0, 0.0, 2.0),
1630 visible: true,
1631 };
1632 let far_obj = TestObject {
1633 id: 20,
1634 mesh_id: 2,
1635 position: glam::Vec3::new(0.0, 0.0, -2.0),
1636 visible: true,
1637 };
1638 let objects: Vec<&dyn ViewportObject> = vec![&far_obj, &near_obj];
1639
1640 let result = pick_scene_cpu(
1642 glam::Vec3::new(0.0, 0.0, 10.0),
1643 glam::Vec3::new(0.0, 0.0, -1.0),
1644 &objects,
1645 &mesh_lookup,
1646 );
1647 assert!(result.is_some(), "expected a hit");
1648 assert_eq!(result.unwrap().id, 10);
1649 }
1650
1651 #[test]
1652 fn test_box_select_hits_inside_rect() {
1653 let view = glam::Mat4::look_at_rh(
1655 glam::Vec3::new(0.0, 0.0, 5.0),
1656 glam::Vec3::ZERO,
1657 glam::Vec3::Y,
1658 );
1659 let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
1660 let vp = proj * view;
1661 let viewport_size = glam::Vec2::new(800.0, 600.0);
1662
1663 let obj = TestObject {
1664 id: 42,
1665 mesh_id: 1,
1666 position: glam::Vec3::ZERO,
1667 visible: true,
1668 };
1669 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1670
1671 let result = box_select(
1673 glam::Vec2::new(300.0, 200.0),
1674 glam::Vec2::new(500.0, 400.0),
1675 &objects,
1676 vp,
1677 viewport_size,
1678 );
1679 assert_eq!(result, vec![42]);
1680 }
1681
1682 #[test]
1683 fn test_box_select_skips_hidden() {
1684 let view = glam::Mat4::look_at_rh(
1685 glam::Vec3::new(0.0, 0.0, 5.0),
1686 glam::Vec3::ZERO,
1687 glam::Vec3::Y,
1688 );
1689 let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
1690 let vp = proj * view;
1691 let viewport_size = glam::Vec2::new(800.0, 600.0);
1692
1693 let obj = TestObject {
1694 id: 42,
1695 mesh_id: 1,
1696 position: glam::Vec3::ZERO,
1697 visible: false,
1698 };
1699 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1700
1701 let result = box_select(
1702 glam::Vec2::new(0.0, 0.0),
1703 glam::Vec2::new(800.0, 600.0),
1704 &objects,
1705 vp,
1706 viewport_size,
1707 );
1708 assert!(result.is_empty());
1709 }
1710
1711 #[test]
1712 fn test_pick_scene_nodes_hit() {
1713 let (positions, indices) = unit_cube_mesh();
1714 let mut mesh_lookup = HashMap::new();
1715 mesh_lookup.insert(0u64, (positions, indices));
1716
1717 let mut scene = crate::scene::scene::Scene::new();
1718 scene.add(
1719 Some(crate::resources::mesh_store::MeshId(0)),
1720 glam::Mat4::IDENTITY,
1721 crate::scene::material::Material::default(),
1722 );
1723 scene.update_transforms();
1724
1725 let result = pick_scene_nodes_cpu(
1726 glam::Vec3::new(0.0, 0.0, 5.0),
1727 glam::Vec3::new(0.0, 0.0, -1.0),
1728 &scene,
1729 &mesh_lookup,
1730 );
1731 assert!(result.is_some());
1732 }
1733
1734 #[test]
1735 fn test_pick_scene_nodes_miss() {
1736 let (positions, indices) = unit_cube_mesh();
1737 let mut mesh_lookup = HashMap::new();
1738 mesh_lookup.insert(0u64, (positions, indices));
1739
1740 let mut scene = crate::scene::scene::Scene::new();
1741 scene.add(
1742 Some(crate::resources::mesh_store::MeshId(0)),
1743 glam::Mat4::IDENTITY,
1744 crate::scene::material::Material::default(),
1745 );
1746 scene.update_transforms();
1747
1748 let result = pick_scene_nodes_cpu(
1749 glam::Vec3::new(100.0, 100.0, 5.0),
1750 glam::Vec3::new(0.0, 0.0, -1.0),
1751 &scene,
1752 &mesh_lookup,
1753 );
1754 assert!(result.is_none());
1755 }
1756
1757 #[test]
1758 fn test_probe_vertex_attribute() {
1759 let (positions, indices) = unit_cube_mesh();
1760 let mut mesh_lookup = HashMap::new();
1761 mesh_lookup.insert(1u64, (positions.clone(), indices.clone()));
1762
1763 let vertex_scalars: Vec<f32> = (0..positions.len()).map(|i| i as f32).collect();
1765
1766 let obj = TestObject {
1767 id: 42,
1768 mesh_id: 1,
1769 position: glam::Vec3::ZERO,
1770 visible: true,
1771 };
1772 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1773
1774 let attr_ref = AttributeRef {
1775 name: "test".to_string(),
1776 kind: AttributeKind::Vertex,
1777 };
1778 let attr_data = AttributeData::Vertex(vertex_scalars);
1779 let bindings = vec![ProbeBinding {
1780 id: 42,
1781 attribute_ref: &attr_ref,
1782 attribute_data: &attr_data,
1783 positions: &positions,
1784 indices: &indices,
1785 }];
1786
1787 let result = pick_scene_with_probe_cpu(
1788 glam::Vec3::new(0.0, 0.0, 5.0),
1789 glam::Vec3::new(0.0, 0.0, -1.0),
1790 &objects,
1791 &mesh_lookup,
1792 &bindings,
1793 );
1794 assert!(result.is_some(), "expected a hit");
1795 let hit = result.unwrap();
1796 assert_eq!(hit.id, 42);
1797 assert!(
1799 hit.scalar_value.is_some(),
1800 "expected scalar_value to be set"
1801 );
1802 }
1803
1804 #[test]
1805 fn test_probe_cell_attribute() {
1806 let (positions, indices) = unit_cube_mesh();
1807 let mut mesh_lookup = HashMap::new();
1808 mesh_lookup.insert(1u64, (positions.clone(), indices.clone()));
1809
1810 let num_triangles = indices.len() / 3;
1812 let cell_scalars: Vec<f32> = (0..num_triangles).map(|i| (i as f32) * 10.0).collect();
1813
1814 let obj = TestObject {
1815 id: 42,
1816 mesh_id: 1,
1817 position: glam::Vec3::ZERO,
1818 visible: true,
1819 };
1820 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1821
1822 let attr_ref = AttributeRef {
1823 name: "pressure".to_string(),
1824 kind: AttributeKind::Cell,
1825 };
1826 let attr_data = AttributeData::Cell(cell_scalars.clone());
1827 let bindings = vec![ProbeBinding {
1828 id: 42,
1829 attribute_ref: &attr_ref,
1830 attribute_data: &attr_data,
1831 positions: &positions,
1832 indices: &indices,
1833 }];
1834
1835 let result = pick_scene_with_probe_cpu(
1836 glam::Vec3::new(0.0, 0.0, 5.0),
1837 glam::Vec3::new(0.0, 0.0, -1.0),
1838 &objects,
1839 &mesh_lookup,
1840 &bindings,
1841 );
1842 assert!(result.is_some());
1843 let hit = result.unwrap();
1844 assert!(hit.scalar_value.is_some());
1846 let val = hit.scalar_value.unwrap();
1847 assert!(
1848 cell_scalars.contains(&val),
1849 "scalar_value {val} not in cell_scalars"
1850 );
1851 }
1852
1853 #[test]
1854 fn test_probe_no_binding_leaves_none() {
1855 let (positions, indices) = unit_cube_mesh();
1856 let mut mesh_lookup = HashMap::new();
1857 mesh_lookup.insert(1u64, (positions, indices));
1858
1859 let obj = TestObject {
1860 id: 42,
1861 mesh_id: 1,
1862 position: glam::Vec3::ZERO,
1863 visible: true,
1864 };
1865 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1866
1867 let result = pick_scene_with_probe_cpu(
1869 glam::Vec3::new(0.0, 0.0, 5.0),
1870 glam::Vec3::new(0.0, 0.0, -1.0),
1871 &objects,
1872 &mesh_lookup,
1873 &[],
1874 );
1875 assert!(result.is_some());
1876 assert!(result.unwrap().scalar_value.is_none());
1877 }
1878
1879 fn make_view_proj() -> glam::Mat4 {
1885 let view = glam::Mat4::look_at_rh(
1886 glam::Vec3::new(0.0, 0.0, 5.0),
1887 glam::Vec3::ZERO,
1888 glam::Vec3::Y,
1889 );
1890 let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
1891 proj * view
1892 }
1893
1894 #[test]
1895 fn test_pick_rect_mesh_full_screen() {
1896 let (positions, indices) = unit_cube_mesh();
1898 let mut mesh_lookup: std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)> =
1899 std::collections::HashMap::new();
1900 mesh_lookup.insert(0, (positions, indices.clone()));
1901
1902 let item = crate::renderer::SceneRenderItem {
1903 mesh_id: crate::resources::mesh_store::MeshId(0),
1904 model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1905 ..Default::default()
1906 };
1907
1908 let view_proj = make_view_proj();
1909 let viewport = glam::Vec2::new(800.0, 600.0);
1910
1911 let result = pick_rect(
1912 glam::Vec2::ZERO,
1913 viewport,
1914 &[item],
1915 &mesh_lookup,
1916 &[],
1917 view_proj,
1918 viewport,
1919 );
1920
1921 assert!(!result.is_empty(), "expected at least one triangle hit");
1923 assert!(result.total_count() > 0);
1924 }
1925
1926 #[test]
1927 fn test_pick_rect_miss() {
1928 let (positions, indices) = unit_cube_mesh();
1930 let mut mesh_lookup: std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)> =
1931 std::collections::HashMap::new();
1932 mesh_lookup.insert(0, (positions, indices));
1933
1934 let item = crate::renderer::SceneRenderItem {
1935 mesh_id: crate::resources::mesh_store::MeshId(0),
1936 model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1937 ..Default::default()
1938 };
1939
1940 let view_proj = make_view_proj();
1941 let viewport = glam::Vec2::new(800.0, 600.0);
1942
1943 let result = pick_rect(
1944 glam::Vec2::new(700.0, 500.0), glam::Vec2::new(799.0, 599.0),
1946 &[item],
1947 &mesh_lookup,
1948 &[],
1949 view_proj,
1950 viewport,
1951 );
1952
1953 assert!(result.is_empty(), "expected no hits in off-center rect");
1954 }
1955
1956 #[test]
1957 fn test_pick_rect_point_cloud() {
1958 let view_proj = make_view_proj();
1960 let viewport = glam::Vec2::new(800.0, 600.0);
1961
1962 let pc = crate::renderer::PointCloudItem {
1963 positions: vec![[0.0, 0.0, 0.0], [0.1, 0.1, 0.0]],
1964 model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1965 id: 99,
1966 ..Default::default()
1967 };
1968
1969 let result = pick_rect(
1970 glam::Vec2::ZERO,
1971 viewport,
1972 &[],
1973 &std::collections::HashMap::new(),
1974 &[pc],
1975 view_proj,
1976 viewport,
1977 );
1978
1979 assert!(!result.is_empty(), "expected point cloud hits");
1980 let hits = result.hits.get(&99).expect("expected hits for id 99");
1981 assert_eq!(
1982 hits.len(),
1983 2,
1984 "both points should be inside the full-screen rect"
1985 );
1986 assert!(
1988 hits.iter().all(|s| s.is_point()),
1989 "expected SubObjectRef::Point entries"
1990 );
1991 assert_eq!(hits[0], SubObjectRef::Point(0));
1992 assert_eq!(hits[1], SubObjectRef::Point(1));
1993 }
1994
1995 #[test]
1996 fn test_pick_rect_skips_invisible() {
1997 let (positions, indices) = unit_cube_mesh();
1998 let mut mesh_lookup: std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)> =
1999 std::collections::HashMap::new();
2000 mesh_lookup.insert(0, (positions, indices));
2001
2002 let item = crate::renderer::SceneRenderItem {
2003 mesh_id: crate::resources::mesh_store::MeshId(0),
2004 model: glam::Mat4::IDENTITY.to_cols_array_2d(),
2005 appearance: crate::scene::material::AppearanceSettings {
2006 hidden: true,
2007 ..Default::default()
2008 },
2009 ..Default::default()
2010 };
2011
2012 let view_proj = make_view_proj();
2013 let viewport = glam::Vec2::new(800.0, 600.0);
2014
2015 let result = pick_rect(
2016 glam::Vec2::ZERO,
2017 viewport,
2018 &[item],
2019 &mesh_lookup,
2020 &[],
2021 view_proj,
2022 viewport,
2023 );
2024
2025 assert!(result.is_empty(), "invisible items should be skipped");
2026 }
2027
2028 #[test]
2029 fn test_pick_rect_result_type() {
2030 let mut r = RectPickResult::default();
2032 assert!(r.is_empty());
2033 assert_eq!(r.total_count(), 0);
2034
2035 r.hits.insert(
2036 1,
2037 vec![
2038 SubObjectRef::Face(0),
2039 SubObjectRef::Face(1),
2040 SubObjectRef::Face(2),
2041 ],
2042 );
2043 r.hits.insert(2, vec![SubObjectRef::Point(5)]);
2044 assert!(!r.is_empty());
2045 assert_eq!(r.total_count(), 4);
2046 }
2047
2048 #[test]
2049 fn test_barycentric_at_vertices() {
2050 let a = glam::Vec3::new(0.0, 0.0, 0.0);
2051 let b = glam::Vec3::new(1.0, 0.0, 0.0);
2052 let c = glam::Vec3::new(0.0, 1.0, 0.0);
2053
2054 let (u, v, w) = super::barycentric(a, a, b, c);
2056 assert!((u - 1.0).abs() < 1e-5, "u={u}");
2057 assert!(v.abs() < 1e-5, "v={v}");
2058 assert!(w.abs() < 1e-5, "w={w}");
2059
2060 let (u, v, w) = super::barycentric(b, a, b, c);
2062 assert!(u.abs() < 1e-5, "u={u}");
2063 assert!((v - 1.0).abs() < 1e-5, "v={v}");
2064 assert!(w.abs() < 1e-5, "w={w}");
2065
2066 let centroid = (a + b + c) / 3.0;
2068 let (u, v, w) = super::barycentric(centroid, a, b, c);
2069 assert!((u - 1.0 / 3.0).abs() < 1e-4, "u={u}");
2070 assert!((v - 1.0 / 3.0).abs() < 1e-4, "v={v}");
2071 assert!((w - 1.0 / 3.0).abs() < 1e-4, "w={w}");
2072 }
2073
2074 fn make_volume_item(
2079 bbox_min: [f32; 3],
2080 bbox_max: [f32; 3],
2081 threshold_min: f32,
2082 threshold_max: f32,
2083 ) -> crate::renderer::VolumeItem {
2084 crate::renderer::VolumeItem {
2085 bbox_min,
2086 bbox_max,
2087 threshold_min,
2088 threshold_max,
2089 ..crate::renderer::VolumeItem::default()
2090 }
2091 }
2092
2093 fn make_volume_data(dims: [u32; 3], fill: f32) -> crate::geometry::marching_cubes::VolumeData {
2094 let n = (dims[0] * dims[1] * dims[2]) as usize;
2095 crate::geometry::marching_cubes::VolumeData {
2096 data: vec![fill; n],
2097 dims,
2098 origin: [0.0; 3],
2099 spacing: [1.0; 3],
2100 }
2101 }
2102
2103 #[test]
2104 fn test_pick_volume_basic_hit() {
2105 let item = make_volume_item([0.0; 3], [3.0, 3.0, 3.0], 0.5, 1.0);
2108 let volume = make_volume_data([3, 3, 3], 0.8);
2109
2110 let hit = super::pick_volume_cpu(
2111 glam::Vec3::new(1.5, 10.0, 1.5),
2112 glam::Vec3::new(0.0, -1.0, 0.0),
2113 42,
2114 &item,
2115 &volume,
2116 );
2117 assert!(hit.is_some(), "expected a hit");
2118 let hit = hit.unwrap();
2119
2120 assert_eq!(hit.id, 42);
2121 assert_eq!(hit.scalar_value, Some(0.8));
2122
2123 let flat = hit.sub_object.unwrap().index();
2125 let nx = 3u32;
2126 let ny = 3u32;
2127 let ix = flat % nx;
2128 let iy = (flat / nx) % ny;
2129 let iz = flat / (nx * ny);
2130 assert_eq!((ix, iy, iz), (1, 2, 1), "expected top-centre voxel");
2131
2132 assert!(hit.world_pos.y > 2.9, "world_pos.y={}", hit.world_pos.y);
2134
2135 assert!(hit.normal.y > 0.9, "normal={:?}", hit.normal);
2137 }
2138
2139 #[test]
2140 fn test_pick_volume_miss_aabb() {
2141 let item = make_volume_item([0.0; 3], [1.0; 3], 0.0, 1.0);
2142 let volume = make_volume_data([4, 4, 4], 0.5);
2143
2144 let hit = super::pick_volume_cpu(
2146 glam::Vec3::new(10.0, 5.0, 0.5),
2147 glam::Vec3::new(0.0, -1.0, 0.0),
2148 1,
2149 &item,
2150 &volume,
2151 );
2152 assert!(hit.is_none(), "expected miss");
2153 }
2154
2155 #[test]
2156 fn test_pick_volume_threshold_miss() {
2157 let item = make_volume_item([0.0; 3], [1.0; 3], 0.5, 1.0);
2159 let volume = make_volume_data([4, 4, 4], 0.3);
2160
2161 let hit = super::pick_volume_cpu(
2162 glam::Vec3::new(0.5, 5.0, 0.5),
2163 glam::Vec3::new(0.0, -1.0, 0.0),
2164 1,
2165 &item,
2166 &volume,
2167 );
2168 assert!(
2169 hit.is_none(),
2170 "expected no hit when all scalars below threshold"
2171 );
2172 }
2173
2174 #[test]
2175 fn test_pick_volume_threshold_skip() {
2176 let item = make_volume_item([0.0; 3], [1.0, 3.0, 1.0], 0.5, 1.0);
2181 let mut volume = make_volume_data([1, 3, 1], 0.0);
2182 volume.data[2] = 0.3;
2184 volume.data[1] = 0.8;
2185 volume.data[0] = 0.8;
2186
2187 let hit = super::pick_volume_cpu(
2188 glam::Vec3::new(0.5, 10.0, 0.5),
2189 glam::Vec3::new(0.0, -1.0, 0.0),
2190 1,
2191 &item,
2192 &volume,
2193 );
2194 assert!(hit.is_some(), "expected a hit");
2195 let hit = hit.unwrap();
2196 let flat = hit.sub_object.unwrap().index();
2197 assert_eq!(flat, 1, "expected iy=1 (flat=1), got flat={flat}");
2198 assert_eq!(hit.scalar_value, Some(0.8));
2199 }
2200
2201 #[test]
2202 fn test_pick_volume_nan_skip() {
2203 let item = make_volume_item([0.0; 3], [1.0, 2.0, 1.0], 0.0, 1.0);
2206 let mut volume = make_volume_data([1, 2, 1], 0.0);
2207 volume.data[1] = f32::NAN;
2208 volume.data[0] = 0.5;
2209
2210 let hit = super::pick_volume_cpu(
2211 glam::Vec3::new(0.5, 10.0, 0.5),
2212 glam::Vec3::new(0.0, -1.0, 0.0),
2213 1,
2214 &item,
2215 &volume,
2216 );
2217 assert!(hit.is_some(), "expected hit after NaN skip");
2218 let hit = hit.unwrap();
2219 assert_eq!(hit.sub_object.unwrap().index(), 0, "expected iy=0 (flat=0)");
2220 assert_eq!(hit.scalar_value, Some(0.5));
2221 }
2222
2223 #[test]
2224 fn test_pick_volume_dda_no_skip() {
2225 let item = make_volume_item([0.0; 3], [10.0, 1.0, 1.0], 0.5, 1.0);
2229 let mut volume = make_volume_data([10, 1, 1], 0.0);
2230 volume.data[9] = 0.8;
2231
2232 let dir = glam::Vec3::new(1.0, 0.0, 0.001).normalize();
2233 let hit = super::pick_volume_cpu(glam::Vec3::new(-1.0, 0.5, 0.5), dir, 1, &item, &volume);
2234 assert!(
2235 hit.is_some(),
2236 "DDA must reach the last voxel without skipping"
2237 );
2238 let flat = hit.unwrap().sub_object.unwrap().index();
2239 assert_eq!(flat, 9, "expected ix=9 (flat=9), got flat={flat}");
2240 }
2241
2242 #[test]
2243 fn test_voxel_world_aabb_identity() {
2244 let item = make_volume_item([0.0; 3], [4.0, 4.0, 4.0], 0.0, 1.0);
2246 let volume = make_volume_data([4, 4, 4], 0.0);
2247
2248 let (lo, hi) = super::voxel_world_aabb(0, &volume, &item);
2250 assert!((lo - glam::Vec3::ZERO).length() < 1e-5, "lo={lo:?}");
2251 assert!((hi - glam::Vec3::ONE).length() < 1e-5, "hi={hi:?}");
2252
2253 let (lo, hi) = super::voxel_world_aabb(1, &volume, &item);
2255 assert!((lo.x - 1.0).abs() < 1e-5 && (hi.x - 2.0).abs() < 1e-5);
2256
2257 let (lo, hi) = super::voxel_world_aabb(57, &volume, &item);
2259 assert!(
2260 (lo - glam::Vec3::new(1.0, 2.0, 3.0)).length() < 1e-5,
2261 "lo={lo:?}"
2262 );
2263 assert!(
2264 (hi - glam::Vec3::new(2.0, 3.0, 4.0)).length() < 1e-5,
2265 "hi={hi:?}"
2266 );
2267 }
2268
2269 #[test]
2270 fn test_voxel_world_aabb_round_trip() {
2271 let item = make_volume_item([0.0; 3], [3.0, 3.0, 3.0], 0.5, 1.0);
2274 let volume = make_volume_data([3, 3, 3], 0.8);
2275
2276 let hit = super::pick_volume_cpu(
2277 glam::Vec3::new(1.5, 10.0, 1.5),
2278 glam::Vec3::new(0.0, -1.0, 0.0),
2279 1,
2280 &item,
2281 &volume,
2282 )
2283 .expect("expected a hit for round-trip test");
2284
2285 let flat = hit.sub_object.unwrap().index();
2286 let (lo, hi) = super::voxel_world_aabb(flat, &volume, &item);
2287
2288 let tol = 1e-3;
2289 assert!(
2290 hit.world_pos.x >= lo.x - tol && hit.world_pos.x <= hi.x + tol,
2291 "world_pos.x={} outside [{}, {}]",
2292 hit.world_pos.x,
2293 lo.x,
2294 hi.x
2295 );
2296 assert!(
2297 hit.world_pos.y >= lo.y - tol && hit.world_pos.y <= hi.y + tol,
2298 "world_pos.y={} outside [{}, {}]",
2299 hit.world_pos.y,
2300 lo.y,
2301 hi.y
2302 );
2303 assert!(
2304 hit.world_pos.z >= lo.z - tol && hit.world_pos.z <= hi.z + tol,
2305 "world_pos.z={} outside [{}, {}]",
2306 hit.world_pos.z,
2307 lo.z,
2308 hi.z
2309 );
2310 }
2311}