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::FaceColor => {
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.visible {
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 = ((p_entry - bbox_min) / extent).clamp(
774 glam::Vec3::splat(eps),
775 glam::Vec3::splat(1.0 - eps),
776 );
777 let mut ix = (frac.x * nx as f32).floor() as i32;
778 let mut iy = (frac.y * ny as f32).floor() as i32;
779 let mut iz = (frac.z * nz as f32).floor() as i32;
780 ix = ix.clamp(0, nx as i32 - 1);
781 iy = iy.clamp(0, ny as i32 - 1);
782 iz = iz.clamp(0, nz as i32 - 1);
783
784 let step_x: i32 = if local_dir.x >= 0.0 { 1 } else { -1 };
786 let step_y: i32 = if local_dir.y >= 0.0 { 1 } else { -1 };
787 let step_z: i32 = if local_dir.z >= 0.0 { 1 } else { -1 };
788
789 let td_x = if local_dir.x.abs() > 1e-12 { cell.x / local_dir.x.abs() } else { f32::INFINITY };
791 let td_y = if local_dir.y.abs() > 1e-12 { cell.y / local_dir.y.abs() } else { f32::INFINITY };
792 let td_z = if local_dir.z.abs() > 1e-12 { cell.z / local_dir.z.abs() } else { f32::INFINITY };
793
794 let next_bx = bbox_min.x + (if step_x > 0 { ix + 1 } else { ix }) as f32 * cell.x;
796 let next_by = bbox_min.y + (if step_y > 0 { iy + 1 } else { iy }) as f32 * cell.y;
797 let next_bz = bbox_min.z + (if step_z > 0 { iz + 1 } else { iz }) as f32 * cell.z;
798
799 let mut tmax_x = if local_dir.x.abs() > 1e-12 {
800 t_start + (next_bx - p_entry.x) / local_dir.x
801 } else {
802 f32::INFINITY
803 };
804 let mut tmax_y = if local_dir.y.abs() > 1e-12 {
805 t_start + (next_by - p_entry.y) / local_dir.y
806 } else {
807 f32::INFINITY
808 };
809 let mut tmax_z = if local_dir.z.abs() > 1e-12 {
810 t_start + (next_bz - p_entry.z) / local_dir.z
811 } else {
812 f32::INFINITY
813 };
814
815 let mut entry_normal_local = glam::Vec3::ZERO;
817 match entry_axis {
818 0 => entry_normal_local.x = entry_sign,
819 1 => entry_normal_local.y = entry_sign,
820 _ => entry_normal_local.z = entry_sign,
821 }
822
823 let mut t_voxel_entry = t_start;
825
826 loop {
827 if ix < 0 || ix >= nx as i32 || iy < 0 || iy >= ny as i32 || iz < 0 || iz >= nz as i32 {
829 break;
830 }
831
832 let flat = ix as u32 + iy as u32 * nx + iz as u32 * nx * ny;
833 let scalar = volume.data[flat as usize];
834
835 if !scalar.is_nan() && scalar >= item.threshold_min && scalar <= item.threshold_max {
837 let local_hit = local_origin + t_voxel_entry * local_dir;
838 let world_pos = model.transform_point3(local_hit);
839 let world_normal = inv_model
841 .transpose()
842 .transform_vector3(entry_normal_local)
843 .normalize();
844
845 #[allow(deprecated)]
846 return Some(PickHit {
847 id,
848 sub_object: Some(SubObjectRef::Voxel(flat)),
849 world_pos,
850 normal: world_normal,
851 triangle_index: u32::MAX,
852 point_index: None,
853 scalar_value: Some(scalar),
854 });
855 }
856
857 if tmax_x <= tmax_y && tmax_x <= tmax_z {
859 if tmax_x > t_exit {
860 break;
861 }
862 t_voxel_entry = tmax_x;
863 tmax_x += td_x;
864 ix += step_x;
865 entry_normal_local = glam::Vec3::new(-(step_x as f32), 0.0, 0.0);
866 } else if tmax_y <= tmax_z {
867 if tmax_y > t_exit {
868 break;
869 }
870 t_voxel_entry = tmax_y;
871 tmax_y += td_y;
872 iy += step_y;
873 entry_normal_local = glam::Vec3::new(0.0, -(step_y as f32), 0.0);
874 } else {
875 if tmax_z > t_exit {
876 break;
877 }
878 t_voxel_entry = tmax_z;
879 tmax_z += td_z;
880 iz += step_z;
881 entry_normal_local = glam::Vec3::new(0.0, 0.0, -(step_z as f32));
882 }
883 }
884
885 None
886}
887
888pub fn voxel_world_aabb(
902 flat_index: u32,
903 volume: &VolumeData,
904 item: &crate::renderer::VolumeItem,
905) -> (glam::Vec3, glam::Vec3) {
906 let [nx, ny, nz] = volume.dims;
907 let ix = flat_index % nx;
908 let iy = (flat_index / nx) % ny;
909 let iz = flat_index / (nx * ny);
910 assert!(
911 ix < nx && iy < ny && iz < nz,
912 "flat_index {} out of bounds for dims {:?}",
913 flat_index,
914 volume.dims
915 );
916
917 let bbox_min = glam::Vec3::from(item.bbox_min);
918 let bbox_max = glam::Vec3::from(item.bbox_max);
919 let cell = (bbox_max - bbox_min) / glam::Vec3::new(nx as f32, ny as f32, nz as f32);
920
921 let local_lo = bbox_min
922 + glam::Vec3::new(ix as f32 * cell.x, iy as f32 * cell.y, iz as f32 * cell.z);
923 let local_hi = local_lo + cell;
924
925 let model = glam::Mat4::from_cols_array_2d(&item.model);
926 let corners = [
927 glam::Vec3::new(local_lo.x, local_lo.y, local_lo.z),
928 glam::Vec3::new(local_hi.x, local_lo.y, local_lo.z),
929 glam::Vec3::new(local_lo.x, local_hi.y, local_lo.z),
930 glam::Vec3::new(local_hi.x, local_hi.y, local_lo.z),
931 glam::Vec3::new(local_lo.x, local_lo.y, local_hi.z),
932 glam::Vec3::new(local_hi.x, local_lo.y, local_hi.z),
933 glam::Vec3::new(local_lo.x, local_hi.y, local_hi.z),
934 glam::Vec3::new(local_hi.x, local_hi.y, local_hi.z),
935 ];
936
937 let world_min = corners
938 .iter()
939 .map(|&c| model.transform_point3(c))
940 .fold(glam::Vec3::splat(f32::INFINITY), |acc, c| acc.min(c));
941 let world_max = corners
942 .iter()
943 .map(|&c| model.transform_point3(c))
944 .fold(glam::Vec3::splat(f32::NEG_INFINITY), |acc, c| acc.max(c));
945
946 (world_min, world_max)
947}
948
949pub fn pick_point_cloud_cpu(
963 click_pos: glam::Vec2,
964 id: u64,
965 item: &crate::renderer::PointCloudItem,
966 view_proj: glam::Mat4,
967 viewport_size: glam::Vec2,
968 radius_px: f32,
969) -> Option<PickHit> {
970 if id == 0 || item.positions.is_empty() {
971 return None;
972 }
973
974 let model = glam::Mat4::from_cols_array_2d(&item.model);
975 let mvp = view_proj * model;
976
977 let mut best_dist_sq = radius_px * radius_px;
978 let mut best_idx: Option<u32> = None;
979 let mut best_world = glam::Vec3::ZERO;
980
981 for (pt_idx, pos) in item.positions.iter().enumerate() {
982 let local = glam::Vec3::from(*pos);
983 let clip = mvp * local.extend(1.0);
984 if clip.w <= 0.0 {
985 continue;
986 }
987 let ndc_x = clip.x / clip.w;
988 let ndc_y = clip.y / clip.w;
989 let sx = (ndc_x + 1.0) * 0.5 * viewport_size.x;
990 let sy = (1.0 - ndc_y) * 0.5 * viewport_size.y;
991 let dx = sx - click_pos.x;
992 let dy = sy - click_pos.y;
993 let dist_sq = dx * dx + dy * dy;
994 if dist_sq < best_dist_sq {
995 best_dist_sq = dist_sq;
996 best_idx = Some(pt_idx as u32);
997 best_world = model.transform_point3(local);
998 }
999 }
1000
1001 let pt_idx = best_idx?;
1002 #[allow(deprecated)]
1003 Some(PickHit {
1004 id,
1005 sub_object: Some(SubObjectRef::Point(pt_idx)),
1006 world_pos: best_world,
1007 normal: glam::Vec3::Y,
1008 triangle_index: u32::MAX,
1009 point_index: Some(pt_idx),
1010 scalar_value: None,
1011 })
1012}
1013
1014pub fn nearest_vertex_on_hit(
1033 hit: &PickHit,
1034 positions: &[[f32; 3]],
1035 indices: &[u32],
1036 model: glam::Mat4,
1037) -> Option<SubObjectRef> {
1038 let face_raw = match hit.sub_object {
1039 Some(SubObjectRef::Face(i)) => i as usize,
1040 _ => return None,
1041 };
1042 let n_tri = indices.len() / 3;
1043 if n_tri == 0 {
1044 return None;
1045 }
1046 let face = if face_raw >= n_tri { face_raw - n_tri } else { face_raw };
1048 if face * 3 + 2 >= indices.len() {
1049 return None;
1050 }
1051 let vi = [
1052 indices[face * 3] as usize,
1053 indices[face * 3 + 1] as usize,
1054 indices[face * 3 + 2] as usize,
1055 ];
1056 let (best_vi, _) = vi
1057 .iter()
1058 .map(|&i| {
1059 let p = model.transform_point3(glam::Vec3::from(positions[i]));
1060 (i, p.distance(hit.world_pos))
1061 })
1062 .fold((vi[0], f32::MAX), |acc, (i, d)| {
1063 if d < acc.1 { (i, d) } else { acc }
1064 });
1065 Some(SubObjectRef::Vertex(best_vi as u32))
1066}
1067
1068pub fn pick_gaussian_splat_cpu(
1089 click_pos: glam::Vec2,
1090 id: u64,
1091 positions: &[[f32; 3]],
1092 model: glam::Mat4,
1093 view_proj: glam::Mat4,
1094 viewport_size: glam::Vec2,
1095 radius_px: f32,
1096) -> Option<PickHit> {
1097 if id == 0 || positions.is_empty() {
1098 return None;
1099 }
1100 let mvp = view_proj * model;
1101 let mut best_dist_sq = radius_px * radius_px;
1102 let mut best_idx: Option<u32> = None;
1103 let mut best_world = glam::Vec3::ZERO;
1104
1105 for (i, pos) in positions.iter().enumerate() {
1106 let local = glam::Vec3::from(*pos);
1107 let clip = mvp * local.extend(1.0);
1108 if clip.w <= 0.0 {
1109 continue;
1110 }
1111 let sx = (clip.x / clip.w + 1.0) * 0.5 * viewport_size.x;
1112 let sy = (1.0 - clip.y / clip.w) * 0.5 * viewport_size.y;
1113 let dx = sx - click_pos.x;
1114 let dy = sy - click_pos.y;
1115 let dist_sq = dx * dx + dy * dy;
1116 if dist_sq < best_dist_sq {
1117 best_dist_sq = dist_sq;
1118 best_idx = Some(i as u32);
1119 best_world = model.transform_point3(local);
1120 }
1121 }
1122
1123 let idx = best_idx?;
1124 #[allow(deprecated)]
1125 Some(PickHit {
1126 id,
1127 sub_object: Some(SubObjectRef::Point(idx)),
1128 world_pos: best_world,
1129 normal: glam::Vec3::Y,
1130 triangle_index: u32::MAX,
1131 point_index: Some(idx),
1132 scalar_value: None,
1133 })
1134}
1135
1136fn ray_tri_mt_ds(
1144 orig: glam::Vec3,
1145 dir: glam::Vec3,
1146 v0: glam::Vec3,
1147 v1: glam::Vec3,
1148 v2: glam::Vec3,
1149) -> Option<f32> {
1150 let e1 = v1 - v0;
1151 let e2 = v2 - v0;
1152 let h = dir.cross(e2);
1153 let a = e1.dot(h);
1154 if a.abs() < 1e-8 {
1155 return None;
1156 }
1157 let f = 1.0 / a;
1158 let s = orig - v0;
1159 let u = f * s.dot(h);
1160 if !(0.0..=1.0).contains(&u) {
1161 return None;
1162 }
1163 let q = s.cross(e1);
1164 let v = f * dir.dot(q);
1165 if v < 0.0 || u + v > 1.0 {
1166 return None;
1167 }
1168 let t = f * e2.dot(q);
1169 if t > 1e-6 { Some(t) } else { None }
1170}
1171
1172const VM_TET_FACES: [[usize; 3]; 4] = [[1, 2, 3], [0, 3, 2], [0, 1, 3], [0, 2, 1]];
1177
1178const VM_HEX_TRIS: [[usize; 3]; 12] = [
1180 [0, 1, 2], [0, 2, 3], [4, 7, 6], [4, 6, 5], [0, 4, 5], [0, 5, 1], [2, 6, 7], [2, 7, 3], [0, 3, 7], [0, 7, 4], [1, 5, 6], [1, 6, 2], ];
1187
1188const VM_PYRAMID_TRIS: [[usize; 3]; 6] = [
1190 [0, 1, 2], [0, 2, 3], [0, 4, 1], [1, 4, 2], [2, 4, 3], [3, 4, 0], ];
1193
1194const VM_WEDGE_TRIS: [[usize; 3]; 8] = [
1196 [0, 2, 1], [3, 4, 5], [0, 1, 4], [0, 4, 3], [1, 2, 5], [1, 5, 4], [2, 0, 3], [2, 3, 5], ];
1201
1202pub fn pick_transparent_volume_mesh_cpu(
1217 ray_origin: glam::Vec3,
1218 ray_dir: glam::Vec3,
1219 id: u64,
1220 model: glam::Mat4,
1221 data: &VolumeMeshData,
1222) -> Option<PickHit> {
1223 if id == 0 || data.cells.is_empty() {
1224 return None;
1225 }
1226 let model_inv = model.inverse();
1227 let local_origin = model_inv.transform_point3(ray_origin);
1228 let local_dir = model_inv.transform_vector3(ray_dir);
1229
1230 let mut best_t = f32::MAX;
1231 let mut best_cell: Option<u32> = None;
1232
1233 for (cell_idx, cell) in data.cells.iter().enumerate() {
1234 let p = |i: usize| glam::Vec3::from(data.positions[cell[i] as usize]);
1235 let tris: &[[usize; 3]] = if cell[4] == CELL_SENTINEL {
1236 &VM_TET_FACES
1237 } else if cell[5] == CELL_SENTINEL {
1238 &VM_PYRAMID_TRIS
1239 } else if cell[6] == CELL_SENTINEL {
1240 &VM_WEDGE_TRIS
1241 } else {
1242 &VM_HEX_TRIS
1243 };
1244 for tri in tris {
1245 if let Some(t) = ray_tri_mt_ds(local_origin, local_dir, p(tri[0]), p(tri[1]), p(tri[2])) {
1246 if t < best_t {
1247 best_t = t;
1248 best_cell = Some(cell_idx as u32);
1249 }
1250 }
1251 }
1252 }
1253
1254 let cell_idx = best_cell?;
1255 let local_hit = local_origin + local_dir * best_t;
1256 let world_hit = model.transform_point3(local_hit);
1257 #[allow(deprecated)]
1258 Some(PickHit {
1259 id,
1260 sub_object: Some(SubObjectRef::Cell(cell_idx)),
1261 world_pos: world_hit,
1262 normal: -ray_dir.normalize(),
1263 triangle_index: u32::MAX,
1264 point_index: None,
1265 scalar_value: None,
1266 })
1267}
1268
1269pub fn pick_volume_rect(
1289 rect_min: glam::Vec2,
1290 rect_max: glam::Vec2,
1291 id: u64,
1292 item: &crate::renderer::VolumeItem,
1293 volume: &VolumeData,
1294 view_proj: glam::Mat4,
1295 viewport_size: glam::Vec2,
1296) -> RectPickResult {
1297 let mut result = RectPickResult::default();
1298 if id == 0 {
1299 return result;
1300 }
1301 let model = glam::Mat4::from_cols_array_2d(&item.model);
1302 let bbox_min = glam::Vec3::from(item.bbox_min);
1303 let bbox_max = glam::Vec3::from(item.bbox_max);
1304 let [nx, ny, nz] = volume.dims;
1305 let cell = (bbox_max - bbox_min) / glam::Vec3::new(nx as f32, ny as f32, nz as f32);
1306 let mvp = view_proj * model;
1307
1308 let mut hits: Vec<SubObjectRef> = Vec::new();
1309 for iz in 0..nz {
1310 for iy in 0..ny {
1311 for ix in 0..nx {
1312 let flat = ix + iy * nx + iz * nx * ny;
1313 let scalar = volume.data[flat as usize];
1314 if scalar.is_nan()
1315 || scalar < item.threshold_min
1316 || scalar > item.threshold_max
1317 {
1318 continue;
1319 }
1320 let local_center = bbox_min
1321 + cell * glam::Vec3::new(ix as f32 + 0.5, iy as f32 + 0.5, iz as f32 + 0.5);
1322 let clip = mvp * local_center.extend(1.0);
1323 if clip.w <= 0.0 {
1324 continue;
1325 }
1326 let sx = (clip.x / clip.w + 1.0) * 0.5 * viewport_size.x;
1327 let sy = (1.0 - clip.y / clip.w) * 0.5 * viewport_size.y;
1328 if sx >= rect_min.x && sx <= rect_max.x && sy >= rect_min.y && sy <= rect_max.y {
1329 hits.push(SubObjectRef::Voxel(flat));
1330 }
1331 }
1332 }
1333 }
1334 if !hits.is_empty() {
1335 result.hits.insert(id, hits);
1336 }
1337 result
1338}
1339
1340pub fn pick_transparent_volume_mesh_rect(
1359 rect_min: glam::Vec2,
1360 rect_max: glam::Vec2,
1361 id: u64,
1362 model: glam::Mat4,
1363 data: &VolumeMeshData,
1364 view_proj: glam::Mat4,
1365 viewport_size: glam::Vec2,
1366) -> RectPickResult {
1367 let mut result = RectPickResult::default();
1368 if id == 0 || data.cells.is_empty() {
1369 return result;
1370 }
1371 let mvp = view_proj * model;
1372 let mut hits: Vec<SubObjectRef> = Vec::new();
1373
1374 for (cell_idx, cell) in data.cells.iter().enumerate() {
1375 let nv: usize = if cell[4] == CELL_SENTINEL {
1376 4
1377 } else if cell[5] == CELL_SENTINEL {
1378 5
1379 } else if cell[6] == CELL_SENTINEL {
1380 6
1381 } else {
1382 8
1383 };
1384 let centroid: glam::Vec3 = cell[..nv]
1385 .iter()
1386 .map(|&vi| glam::Vec3::from(data.positions[vi as usize]))
1387 .sum::<glam::Vec3>()
1388 / nv as f32;
1389 let clip = mvp * centroid.extend(1.0);
1390 if clip.w <= 0.0 {
1391 continue;
1392 }
1393 let sx = (clip.x / clip.w + 1.0) * 0.5 * viewport_size.x;
1394 let sy = (1.0 - clip.y / clip.w) * 0.5 * viewport_size.y;
1395 if sx >= rect_min.x && sx <= rect_max.x && sy >= rect_min.y && sy <= rect_max.y {
1396 hits.push(SubObjectRef::Cell(cell_idx as u32));
1397 }
1398 }
1399 if !hits.is_empty() {
1400 result.hits.insert(id, hits);
1401 }
1402 result
1403}
1404
1405pub fn pick_gaussian_splat_rect(
1424 rect_min: glam::Vec2,
1425 rect_max: glam::Vec2,
1426 id: u64,
1427 positions: &[[f32; 3]],
1428 model: glam::Mat4,
1429 view_proj: glam::Mat4,
1430 viewport_size: glam::Vec2,
1431) -> RectPickResult {
1432 let mut result = RectPickResult::default();
1433 if id == 0 || positions.is_empty() {
1434 return result;
1435 }
1436 let mvp = view_proj * model;
1437 let mut hits: Vec<SubObjectRef> = Vec::new();
1438
1439 for (i, pos) in positions.iter().enumerate() {
1440 let local = glam::Vec3::from(*pos);
1441 let clip = mvp * local.extend(1.0);
1442 if clip.w <= 0.0 {
1443 continue;
1444 }
1445 let sx = (clip.x / clip.w + 1.0) * 0.5 * viewport_size.x;
1446 let sy = (1.0 - clip.y / clip.w) * 0.5 * viewport_size.y;
1447 if sx >= rect_min.x && sx <= rect_max.x && sy >= rect_min.y && sy <= rect_max.y {
1448 hits.push(SubObjectRef::Point(i as u32));
1449 }
1450 }
1451 if !hits.is_empty() {
1452 result.hits.insert(id, hits);
1453 }
1454 result
1455}
1456
1457#[cfg(test)]
1458mod tests {
1459 use super::*;
1460 use crate::scene::traits::ViewportObject;
1461 use std::collections::HashMap;
1462
1463 struct TestObject {
1464 id: u64,
1465 mesh_id: u64,
1466 position: glam::Vec3,
1467 visible: bool,
1468 }
1469
1470 impl ViewportObject for TestObject {
1471 fn id(&self) -> u64 {
1472 self.id
1473 }
1474 fn mesh_id(&self) -> Option<u64> {
1475 Some(self.mesh_id)
1476 }
1477 fn model_matrix(&self) -> glam::Mat4 {
1478 glam::Mat4::from_translation(self.position)
1479 }
1480 fn position(&self) -> glam::Vec3 {
1481 self.position
1482 }
1483 fn rotation(&self) -> glam::Quat {
1484 glam::Quat::IDENTITY
1485 }
1486 fn is_visible(&self) -> bool {
1487 self.visible
1488 }
1489 fn color(&self) -> glam::Vec3 {
1490 glam::Vec3::ONE
1491 }
1492 }
1493
1494 fn unit_cube_mesh() -> (Vec<[f32; 3]>, Vec<u32>) {
1496 let positions = vec![
1497 [-0.5, -0.5, -0.5],
1498 [0.5, -0.5, -0.5],
1499 [0.5, 0.5, -0.5],
1500 [-0.5, 0.5, -0.5],
1501 [-0.5, -0.5, 0.5],
1502 [0.5, -0.5, 0.5],
1503 [0.5, 0.5, 0.5],
1504 [-0.5, 0.5, 0.5],
1505 ];
1506 let indices = vec![
1507 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, ];
1514 (positions, indices)
1515 }
1516
1517 #[test]
1518 fn test_screen_to_ray_center() {
1519 let vp_inv = glam::Mat4::IDENTITY;
1521 let (origin, dir) = screen_to_ray(
1522 glam::Vec2::new(400.0, 300.0),
1523 glam::Vec2::new(800.0, 600.0),
1524 vp_inv,
1525 );
1526 assert!(origin.x.abs() < 1e-3, "origin.x={}", origin.x);
1528 assert!(origin.y.abs() < 1e-3, "origin.y={}", origin.y);
1529 assert!(dir.z.abs() > 0.9, "dir should be along Z, got {dir:?}");
1530 }
1531
1532 #[test]
1533 fn test_pick_scene_hit() {
1534 let (positions, indices) = unit_cube_mesh();
1535 let mut mesh_lookup = HashMap::new();
1536 mesh_lookup.insert(1u64, (positions, indices));
1537
1538 let obj = TestObject {
1539 id: 42,
1540 mesh_id: 1,
1541 position: glam::Vec3::ZERO,
1542 visible: true,
1543 };
1544 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1545
1546 let result = pick_scene_cpu(
1548 glam::Vec3::new(0.0, 0.0, 5.0),
1549 glam::Vec3::new(0.0, 0.0, -1.0),
1550 &objects,
1551 &mesh_lookup,
1552 );
1553 assert!(result.is_some(), "expected a hit");
1554 let hit = result.unwrap();
1555 assert_eq!(hit.id, 42);
1556 assert!(
1558 (hit.world_pos.z - 0.5).abs() < 0.01,
1559 "world_pos.z={}",
1560 hit.world_pos.z
1561 );
1562 assert!(hit.normal.z > 0.9, "normal={:?}", hit.normal);
1564 }
1565
1566 #[test]
1567 fn test_pick_scene_miss() {
1568 let (positions, indices) = unit_cube_mesh();
1569 let mut mesh_lookup = HashMap::new();
1570 mesh_lookup.insert(1u64, (positions, indices));
1571
1572 let obj = TestObject {
1573 id: 42,
1574 mesh_id: 1,
1575 position: glam::Vec3::ZERO,
1576 visible: true,
1577 };
1578 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1579
1580 let result = pick_scene_cpu(
1582 glam::Vec3::new(100.0, 100.0, 5.0),
1583 glam::Vec3::new(0.0, 0.0, -1.0),
1584 &objects,
1585 &mesh_lookup,
1586 );
1587 assert!(result.is_none());
1588 }
1589
1590 #[test]
1591 fn test_pick_nearest_wins() {
1592 let (positions, indices) = unit_cube_mesh();
1593 let mut mesh_lookup = HashMap::new();
1594 mesh_lookup.insert(1u64, (positions.clone(), indices.clone()));
1595 mesh_lookup.insert(2u64, (positions, indices));
1596
1597 let near_obj = TestObject {
1598 id: 10,
1599 mesh_id: 1,
1600 position: glam::Vec3::new(0.0, 0.0, 2.0),
1601 visible: true,
1602 };
1603 let far_obj = TestObject {
1604 id: 20,
1605 mesh_id: 2,
1606 position: glam::Vec3::new(0.0, 0.0, -2.0),
1607 visible: true,
1608 };
1609 let objects: Vec<&dyn ViewportObject> = vec![&far_obj, &near_obj];
1610
1611 let result = pick_scene_cpu(
1613 glam::Vec3::new(0.0, 0.0, 10.0),
1614 glam::Vec3::new(0.0, 0.0, -1.0),
1615 &objects,
1616 &mesh_lookup,
1617 );
1618 assert!(result.is_some(), "expected a hit");
1619 assert_eq!(result.unwrap().id, 10);
1620 }
1621
1622 #[test]
1623 fn test_box_select_hits_inside_rect() {
1624 let view = glam::Mat4::look_at_rh(
1626 glam::Vec3::new(0.0, 0.0, 5.0),
1627 glam::Vec3::ZERO,
1628 glam::Vec3::Y,
1629 );
1630 let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
1631 let vp = proj * view;
1632 let viewport_size = glam::Vec2::new(800.0, 600.0);
1633
1634 let obj = TestObject {
1635 id: 42,
1636 mesh_id: 1,
1637 position: glam::Vec3::ZERO,
1638 visible: true,
1639 };
1640 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1641
1642 let result = box_select(
1644 glam::Vec2::new(300.0, 200.0),
1645 glam::Vec2::new(500.0, 400.0),
1646 &objects,
1647 vp,
1648 viewport_size,
1649 );
1650 assert_eq!(result, vec![42]);
1651 }
1652
1653 #[test]
1654 fn test_box_select_skips_hidden() {
1655 let view = glam::Mat4::look_at_rh(
1656 glam::Vec3::new(0.0, 0.0, 5.0),
1657 glam::Vec3::ZERO,
1658 glam::Vec3::Y,
1659 );
1660 let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
1661 let vp = proj * view;
1662 let viewport_size = glam::Vec2::new(800.0, 600.0);
1663
1664 let obj = TestObject {
1665 id: 42,
1666 mesh_id: 1,
1667 position: glam::Vec3::ZERO,
1668 visible: false,
1669 };
1670 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1671
1672 let result = box_select(
1673 glam::Vec2::new(0.0, 0.0),
1674 glam::Vec2::new(800.0, 600.0),
1675 &objects,
1676 vp,
1677 viewport_size,
1678 );
1679 assert!(result.is_empty());
1680 }
1681
1682 #[test]
1683 fn test_pick_scene_nodes_hit() {
1684 let (positions, indices) = unit_cube_mesh();
1685 let mut mesh_lookup = HashMap::new();
1686 mesh_lookup.insert(0u64, (positions, indices));
1687
1688 let mut scene = crate::scene::scene::Scene::new();
1689 scene.add(
1690 Some(crate::resources::mesh_store::MeshId(0)),
1691 glam::Mat4::IDENTITY,
1692 crate::scene::material::Material::default(),
1693 );
1694 scene.update_transforms();
1695
1696 let result = pick_scene_nodes_cpu(
1697 glam::Vec3::new(0.0, 0.0, 5.0),
1698 glam::Vec3::new(0.0, 0.0, -1.0),
1699 &scene,
1700 &mesh_lookup,
1701 );
1702 assert!(result.is_some());
1703 }
1704
1705 #[test]
1706 fn test_pick_scene_nodes_miss() {
1707 let (positions, indices) = unit_cube_mesh();
1708 let mut mesh_lookup = HashMap::new();
1709 mesh_lookup.insert(0u64, (positions, indices));
1710
1711 let mut scene = crate::scene::scene::Scene::new();
1712 scene.add(
1713 Some(crate::resources::mesh_store::MeshId(0)),
1714 glam::Mat4::IDENTITY,
1715 crate::scene::material::Material::default(),
1716 );
1717 scene.update_transforms();
1718
1719 let result = pick_scene_nodes_cpu(
1720 glam::Vec3::new(100.0, 100.0, 5.0),
1721 glam::Vec3::new(0.0, 0.0, -1.0),
1722 &scene,
1723 &mesh_lookup,
1724 );
1725 assert!(result.is_none());
1726 }
1727
1728 #[test]
1729 fn test_probe_vertex_attribute() {
1730 let (positions, indices) = unit_cube_mesh();
1731 let mut mesh_lookup = HashMap::new();
1732 mesh_lookup.insert(1u64, (positions.clone(), indices.clone()));
1733
1734 let vertex_scalars: Vec<f32> = (0..positions.len()).map(|i| i as f32).collect();
1736
1737 let obj = TestObject {
1738 id: 42,
1739 mesh_id: 1,
1740 position: glam::Vec3::ZERO,
1741 visible: true,
1742 };
1743 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1744
1745 let attr_ref = AttributeRef {
1746 name: "test".to_string(),
1747 kind: AttributeKind::Vertex,
1748 };
1749 let attr_data = AttributeData::Vertex(vertex_scalars);
1750 let bindings = vec![ProbeBinding {
1751 id: 42,
1752 attribute_ref: &attr_ref,
1753 attribute_data: &attr_data,
1754 positions: &positions,
1755 indices: &indices,
1756 }];
1757
1758 let result = pick_scene_with_probe_cpu(
1759 glam::Vec3::new(0.0, 0.0, 5.0),
1760 glam::Vec3::new(0.0, 0.0, -1.0),
1761 &objects,
1762 &mesh_lookup,
1763 &bindings,
1764 );
1765 assert!(result.is_some(), "expected a hit");
1766 let hit = result.unwrap();
1767 assert_eq!(hit.id, 42);
1768 assert!(
1770 hit.scalar_value.is_some(),
1771 "expected scalar_value to be set"
1772 );
1773 }
1774
1775 #[test]
1776 fn test_probe_cell_attribute() {
1777 let (positions, indices) = unit_cube_mesh();
1778 let mut mesh_lookup = HashMap::new();
1779 mesh_lookup.insert(1u64, (positions.clone(), indices.clone()));
1780
1781 let num_triangles = indices.len() / 3;
1783 let cell_scalars: Vec<f32> = (0..num_triangles).map(|i| (i as f32) * 10.0).collect();
1784
1785 let obj = TestObject {
1786 id: 42,
1787 mesh_id: 1,
1788 position: glam::Vec3::ZERO,
1789 visible: true,
1790 };
1791 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1792
1793 let attr_ref = AttributeRef {
1794 name: "pressure".to_string(),
1795 kind: AttributeKind::Cell,
1796 };
1797 let attr_data = AttributeData::Cell(cell_scalars.clone());
1798 let bindings = vec![ProbeBinding {
1799 id: 42,
1800 attribute_ref: &attr_ref,
1801 attribute_data: &attr_data,
1802 positions: &positions,
1803 indices: &indices,
1804 }];
1805
1806 let result = pick_scene_with_probe_cpu(
1807 glam::Vec3::new(0.0, 0.0, 5.0),
1808 glam::Vec3::new(0.0, 0.0, -1.0),
1809 &objects,
1810 &mesh_lookup,
1811 &bindings,
1812 );
1813 assert!(result.is_some());
1814 let hit = result.unwrap();
1815 assert!(hit.scalar_value.is_some());
1817 let val = hit.scalar_value.unwrap();
1818 assert!(
1819 cell_scalars.contains(&val),
1820 "scalar_value {val} not in cell_scalars"
1821 );
1822 }
1823
1824 #[test]
1825 fn test_probe_no_binding_leaves_none() {
1826 let (positions, indices) = unit_cube_mesh();
1827 let mut mesh_lookup = HashMap::new();
1828 mesh_lookup.insert(1u64, (positions, indices));
1829
1830 let obj = TestObject {
1831 id: 42,
1832 mesh_id: 1,
1833 position: glam::Vec3::ZERO,
1834 visible: true,
1835 };
1836 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1837
1838 let result = pick_scene_with_probe_cpu(
1840 glam::Vec3::new(0.0, 0.0, 5.0),
1841 glam::Vec3::new(0.0, 0.0, -1.0),
1842 &objects,
1843 &mesh_lookup,
1844 &[],
1845 );
1846 assert!(result.is_some());
1847 assert!(result.unwrap().scalar_value.is_none());
1848 }
1849
1850 fn make_view_proj() -> glam::Mat4 {
1856 let view = glam::Mat4::look_at_rh(
1857 glam::Vec3::new(0.0, 0.0, 5.0),
1858 glam::Vec3::ZERO,
1859 glam::Vec3::Y,
1860 );
1861 let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
1862 proj * view
1863 }
1864
1865 #[test]
1866 fn test_pick_rect_mesh_full_screen() {
1867 let (positions, indices) = unit_cube_mesh();
1869 let mut mesh_lookup: std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)> =
1870 std::collections::HashMap::new();
1871 mesh_lookup.insert(0, (positions, indices.clone()));
1872
1873 let item = crate::renderer::SceneRenderItem {
1874 mesh_id: crate::resources::mesh_store::MeshId(0),
1875 model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1876 visible: true,
1877 ..Default::default()
1878 };
1879
1880 let view_proj = make_view_proj();
1881 let viewport = glam::Vec2::new(800.0, 600.0);
1882
1883 let result = pick_rect(
1884 glam::Vec2::ZERO,
1885 viewport,
1886 &[item],
1887 &mesh_lookup,
1888 &[],
1889 view_proj,
1890 viewport,
1891 );
1892
1893 assert!(!result.is_empty(), "expected at least one triangle hit");
1895 assert!(result.total_count() > 0);
1896 }
1897
1898 #[test]
1899 fn test_pick_rect_miss() {
1900 let (positions, indices) = unit_cube_mesh();
1902 let mut mesh_lookup: std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)> =
1903 std::collections::HashMap::new();
1904 mesh_lookup.insert(0, (positions, indices));
1905
1906 let item = crate::renderer::SceneRenderItem {
1907 mesh_id: crate::resources::mesh_store::MeshId(0),
1908 model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1909 visible: true,
1910 ..Default::default()
1911 };
1912
1913 let view_proj = make_view_proj();
1914 let viewport = glam::Vec2::new(800.0, 600.0);
1915
1916 let result = pick_rect(
1917 glam::Vec2::new(700.0, 500.0), glam::Vec2::new(799.0, 599.0),
1919 &[item],
1920 &mesh_lookup,
1921 &[],
1922 view_proj,
1923 viewport,
1924 );
1925
1926 assert!(result.is_empty(), "expected no hits in off-center rect");
1927 }
1928
1929 #[test]
1930 fn test_pick_rect_point_cloud() {
1931 let view_proj = make_view_proj();
1933 let viewport = glam::Vec2::new(800.0, 600.0);
1934
1935 let pc = crate::renderer::PointCloudItem {
1936 positions: vec![[0.0, 0.0, 0.0], [0.1, 0.1, 0.0]],
1937 model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1938 id: 99,
1939 ..Default::default()
1940 };
1941
1942 let result = pick_rect(
1943 glam::Vec2::ZERO,
1944 viewport,
1945 &[],
1946 &std::collections::HashMap::new(),
1947 &[pc],
1948 view_proj,
1949 viewport,
1950 );
1951
1952 assert!(!result.is_empty(), "expected point cloud hits");
1953 let hits = result.hits.get(&99).expect("expected hits for id 99");
1954 assert_eq!(
1955 hits.len(),
1956 2,
1957 "both points should be inside the full-screen rect"
1958 );
1959 assert!(
1961 hits.iter().all(|s| s.is_point()),
1962 "expected SubObjectRef::Point entries"
1963 );
1964 assert_eq!(hits[0], SubObjectRef::Point(0));
1965 assert_eq!(hits[1], SubObjectRef::Point(1));
1966 }
1967
1968 #[test]
1969 fn test_pick_rect_skips_invisible() {
1970 let (positions, indices) = unit_cube_mesh();
1971 let mut mesh_lookup: std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)> =
1972 std::collections::HashMap::new();
1973 mesh_lookup.insert(0, (positions, indices));
1974
1975 let item = crate::renderer::SceneRenderItem {
1976 mesh_id: crate::resources::mesh_store::MeshId(0),
1977 model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1978 visible: false, ..Default::default()
1980 };
1981
1982 let view_proj = make_view_proj();
1983 let viewport = glam::Vec2::new(800.0, 600.0);
1984
1985 let result = pick_rect(
1986 glam::Vec2::ZERO,
1987 viewport,
1988 &[item],
1989 &mesh_lookup,
1990 &[],
1991 view_proj,
1992 viewport,
1993 );
1994
1995 assert!(result.is_empty(), "invisible items should be skipped");
1996 }
1997
1998 #[test]
1999 fn test_pick_rect_result_type() {
2000 let mut r = RectPickResult::default();
2002 assert!(r.is_empty());
2003 assert_eq!(r.total_count(), 0);
2004
2005 r.hits.insert(
2006 1,
2007 vec![
2008 SubObjectRef::Face(0),
2009 SubObjectRef::Face(1),
2010 SubObjectRef::Face(2),
2011 ],
2012 );
2013 r.hits.insert(2, vec![SubObjectRef::Point(5)]);
2014 assert!(!r.is_empty());
2015 assert_eq!(r.total_count(), 4);
2016 }
2017
2018 #[test]
2019 fn test_barycentric_at_vertices() {
2020 let a = glam::Vec3::new(0.0, 0.0, 0.0);
2021 let b = glam::Vec3::new(1.0, 0.0, 0.0);
2022 let c = glam::Vec3::new(0.0, 1.0, 0.0);
2023
2024 let (u, v, w) = super::barycentric(a, a, b, c);
2026 assert!((u - 1.0).abs() < 1e-5, "u={u}");
2027 assert!(v.abs() < 1e-5, "v={v}");
2028 assert!(w.abs() < 1e-5, "w={w}");
2029
2030 let (u, v, w) = super::barycentric(b, a, b, c);
2032 assert!(u.abs() < 1e-5, "u={u}");
2033 assert!((v - 1.0).abs() < 1e-5, "v={v}");
2034 assert!(w.abs() < 1e-5, "w={w}");
2035
2036 let centroid = (a + b + c) / 3.0;
2038 let (u, v, w) = super::barycentric(centroid, a, b, c);
2039 assert!((u - 1.0 / 3.0).abs() < 1e-4, "u={u}");
2040 assert!((v - 1.0 / 3.0).abs() < 1e-4, "v={v}");
2041 assert!((w - 1.0 / 3.0).abs() < 1e-4, "w={w}");
2042 }
2043
2044 fn make_volume_item(
2049 bbox_min: [f32; 3],
2050 bbox_max: [f32; 3],
2051 threshold_min: f32,
2052 threshold_max: f32,
2053 ) -> crate::renderer::VolumeItem {
2054 crate::renderer::VolumeItem {
2055 bbox_min,
2056 bbox_max,
2057 threshold_min,
2058 threshold_max,
2059 ..crate::renderer::VolumeItem::default()
2060 }
2061 }
2062
2063 fn make_volume_data(
2064 dims: [u32; 3],
2065 fill: f32,
2066 ) -> crate::geometry::marching_cubes::VolumeData {
2067 let n = (dims[0] * dims[1] * dims[2]) as usize;
2068 crate::geometry::marching_cubes::VolumeData {
2069 data: vec![fill; n],
2070 dims,
2071 origin: [0.0; 3],
2072 spacing: [1.0; 3],
2073 }
2074 }
2075
2076 #[test]
2077 fn test_pick_volume_basic_hit() {
2078 let item = make_volume_item([0.0; 3], [3.0, 3.0, 3.0], 0.5, 1.0);
2081 let volume = make_volume_data([3, 3, 3], 0.8);
2082
2083 let hit = super::pick_volume_cpu(
2084 glam::Vec3::new(1.5, 10.0, 1.5),
2085 glam::Vec3::new(0.0, -1.0, 0.0),
2086 42,
2087 &item,
2088 &volume,
2089 );
2090 assert!(hit.is_some(), "expected a hit");
2091 let hit = hit.unwrap();
2092
2093 assert_eq!(hit.id, 42);
2094 assert_eq!(hit.scalar_value, Some(0.8));
2095
2096 let flat = hit.sub_object.unwrap().index();
2098 let nx = 3u32;
2099 let ny = 3u32;
2100 let ix = flat % nx;
2101 let iy = (flat / nx) % ny;
2102 let iz = flat / (nx * ny);
2103 assert_eq!((ix, iy, iz), (1, 2, 1), "expected top-centre voxel");
2104
2105 assert!(hit.world_pos.y > 2.9, "world_pos.y={}", hit.world_pos.y);
2107
2108 assert!(hit.normal.y > 0.9, "normal={:?}", hit.normal);
2110 }
2111
2112 #[test]
2113 fn test_pick_volume_miss_aabb() {
2114 let item = make_volume_item([0.0; 3], [1.0; 3], 0.0, 1.0);
2115 let volume = make_volume_data([4, 4, 4], 0.5);
2116
2117 let hit = super::pick_volume_cpu(
2119 glam::Vec3::new(10.0, 5.0, 0.5),
2120 glam::Vec3::new(0.0, -1.0, 0.0),
2121 1,
2122 &item,
2123 &volume,
2124 );
2125 assert!(hit.is_none(), "expected miss");
2126 }
2127
2128 #[test]
2129 fn test_pick_volume_threshold_miss() {
2130 let item = make_volume_item([0.0; 3], [1.0; 3], 0.5, 1.0);
2132 let volume = make_volume_data([4, 4, 4], 0.3);
2133
2134 let hit = super::pick_volume_cpu(
2135 glam::Vec3::new(0.5, 5.0, 0.5),
2136 glam::Vec3::new(0.0, -1.0, 0.0),
2137 1,
2138 &item,
2139 &volume,
2140 );
2141 assert!(hit.is_none(), "expected no hit when all scalars below threshold");
2142 }
2143
2144 #[test]
2145 fn test_pick_volume_threshold_skip() {
2146 let item = make_volume_item([0.0; 3], [1.0, 3.0, 1.0], 0.5, 1.0);
2151 let mut volume = make_volume_data([1, 3, 1], 0.0);
2152 volume.data[2] = 0.3;
2154 volume.data[1] = 0.8;
2155 volume.data[0] = 0.8;
2156
2157 let hit = super::pick_volume_cpu(
2158 glam::Vec3::new(0.5, 10.0, 0.5),
2159 glam::Vec3::new(0.0, -1.0, 0.0),
2160 1,
2161 &item,
2162 &volume,
2163 );
2164 assert!(hit.is_some(), "expected a hit");
2165 let hit = hit.unwrap();
2166 let flat = hit.sub_object.unwrap().index();
2167 assert_eq!(flat, 1, "expected iy=1 (flat=1), got flat={flat}");
2168 assert_eq!(hit.scalar_value, Some(0.8));
2169 }
2170
2171 #[test]
2172 fn test_pick_volume_nan_skip() {
2173 let item = make_volume_item([0.0; 3], [1.0, 2.0, 1.0], 0.0, 1.0);
2176 let mut volume = make_volume_data([1, 2, 1], 0.0);
2177 volume.data[1] = f32::NAN;
2178 volume.data[0] = 0.5;
2179
2180 let hit = super::pick_volume_cpu(
2181 glam::Vec3::new(0.5, 10.0, 0.5),
2182 glam::Vec3::new(0.0, -1.0, 0.0),
2183 1,
2184 &item,
2185 &volume,
2186 );
2187 assert!(hit.is_some(), "expected hit after NaN skip");
2188 let hit = hit.unwrap();
2189 assert_eq!(hit.sub_object.unwrap().index(), 0, "expected iy=0 (flat=0)");
2190 assert_eq!(hit.scalar_value, Some(0.5));
2191 }
2192
2193 #[test]
2194 fn test_pick_volume_dda_no_skip() {
2195 let item = make_volume_item([0.0; 3], [10.0, 1.0, 1.0], 0.5, 1.0);
2199 let mut volume = make_volume_data([10, 1, 1], 0.0);
2200 volume.data[9] = 0.8;
2201
2202 let dir = glam::Vec3::new(1.0, 0.0, 0.001).normalize();
2203 let hit = super::pick_volume_cpu(
2204 glam::Vec3::new(-1.0, 0.5, 0.5),
2205 dir,
2206 1,
2207 &item,
2208 &volume,
2209 );
2210 assert!(hit.is_some(), "DDA must reach the last voxel without skipping");
2211 let flat = hit.unwrap().sub_object.unwrap().index();
2212 assert_eq!(flat, 9, "expected ix=9 (flat=9), got flat={flat}");
2213 }
2214
2215 #[test]
2216 fn test_voxel_world_aabb_identity() {
2217 let item = make_volume_item([0.0; 3], [4.0, 4.0, 4.0], 0.0, 1.0);
2219 let volume = make_volume_data([4, 4, 4], 0.0);
2220
2221 let (lo, hi) = super::voxel_world_aabb(0, &volume, &item);
2223 assert!((lo - glam::Vec3::ZERO).length() < 1e-5, "lo={lo:?}");
2224 assert!((hi - glam::Vec3::ONE).length() < 1e-5, "hi={hi:?}");
2225
2226 let (lo, hi) = super::voxel_world_aabb(1, &volume, &item);
2228 assert!((lo.x - 1.0).abs() < 1e-5 && (hi.x - 2.0).abs() < 1e-5);
2229
2230 let (lo, hi) = super::voxel_world_aabb(57, &volume, &item);
2232 assert!((lo - glam::Vec3::new(1.0, 2.0, 3.0)).length() < 1e-5, "lo={lo:?}");
2233 assert!((hi - glam::Vec3::new(2.0, 3.0, 4.0)).length() < 1e-5, "hi={hi:?}");
2234 }
2235
2236 #[test]
2237 fn test_voxel_world_aabb_round_trip() {
2238 let item = make_volume_item([0.0; 3], [3.0, 3.0, 3.0], 0.5, 1.0);
2241 let volume = make_volume_data([3, 3, 3], 0.8);
2242
2243 let hit = super::pick_volume_cpu(
2244 glam::Vec3::new(1.5, 10.0, 1.5),
2245 glam::Vec3::new(0.0, -1.0, 0.0),
2246 1,
2247 &item,
2248 &volume,
2249 )
2250 .expect("expected a hit for round-trip test");
2251
2252 let flat = hit.sub_object.unwrap().index();
2253 let (lo, hi) = super::voxel_world_aabb(flat, &volume, &item);
2254
2255 let tol = 1e-3;
2256 assert!(
2257 hit.world_pos.x >= lo.x - tol && hit.world_pos.x <= hi.x + tol,
2258 "world_pos.x={} outside [{}, {}]",
2259 hit.world_pos.x,
2260 lo.x,
2261 hi.x
2262 );
2263 assert!(
2264 hit.world_pos.y >= lo.y - tol && hit.world_pos.y <= hi.y + tol,
2265 "world_pos.y={} outside [{}, {}]",
2266 hit.world_pos.y,
2267 lo.y,
2268 hi.y
2269 );
2270 assert!(
2271 hit.world_pos.z >= lo.z - tol && hit.world_pos.z <= hi.z + tol,
2272 "world_pos.z={} outside [{}, {}]",
2273 hit.world_pos.z,
2274 lo.z,
2275 hi.z
2276 );
2277 }
2278}