1use crate::geometry::marching_cubes::VolumeData;
18use crate::interaction::sub_object::SubObjectRef;
19use crate::resources::volume_mesh::{CELL_SENTINEL, VolumeMeshData};
20use crate::resources::{AttributeData, AttributeKind, AttributeRef};
21use crate::scene::traits::ViewportObject;
22use parry3d::math::{Pose, Vector};
23use parry3d::query::{Ray, RayCast};
24
25#[derive(Clone, Copy, Debug)]
34#[non_exhaustive]
35pub struct PickHit {
36 pub id: u64,
38 pub sub_object: Option<SubObjectRef>,
43 pub world_pos: glam::Vec3,
45 pub normal: glam::Vec3,
47 #[deprecated(since = "0.5.0", note = "use `sub_object` instead")]
52 pub triangle_index: u32,
53 #[deprecated(since = "0.5.0", note = "use `sub_object` instead")]
58 pub point_index: Option<u32>,
59 pub scalar_value: Option<f32>,
66}
67
68impl PickHit {
69 #[allow(deprecated)]
72 pub fn object_hit(id: u64, world_pos: glam::Vec3, normal: glam::Vec3) -> Self {
73 Self {
74 id,
75 sub_object: None,
76 world_pos,
77 normal,
78 triangle_index: u32::MAX,
79 point_index: None,
80 scalar_value: None,
81 }
82 }
83}
84
85#[derive(Clone, Copy, Debug)]
97#[non_exhaustive]
98pub struct GpuPickHit {
99 pub object_id: crate::renderer::PickId,
106 pub depth: f32,
115}
116
117pub fn screen_to_ray(
130 screen_pos: glam::Vec2,
131 viewport_size: glam::Vec2,
132 view_proj_inv: glam::Mat4,
133) -> (glam::Vec3, glam::Vec3) {
134 let ndc_x = (screen_pos.x / viewport_size.x) * 2.0 - 1.0;
135 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));
137 let far = view_proj_inv.project_point3(glam::Vec3::new(ndc_x, ndc_y, 1.0));
138 let dir = (far - near).normalize();
139 (near, dir)
140}
141
142pub fn pick_scene_cpu(
151 ray_origin: glam::Vec3,
152 ray_dir: glam::Vec3,
153 objects: &[&dyn ViewportObject],
154 mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
155) -> Option<PickHit> {
156 let ray = Ray::new(
158 Vector::new(ray_origin.x, ray_origin.y, ray_origin.z),
159 Vector::new(ray_dir.x, ray_dir.y, ray_dir.z),
160 );
161
162 let mut best_hit: Option<(u64, f32, PickHit)> = None;
163
164 for obj in objects {
165 if !obj.is_visible() {
166 continue;
167 }
168 let Some(mesh_id) = obj.mesh_id() else {
169 continue;
170 };
171
172 if let Some((positions, indices)) = mesh_lookup.get(&mesh_id) {
173 let s = obj.scale();
178 let verts: Vec<Vector> = positions
179 .iter()
180 .map(|p: &[f32; 3]| Vector::new(p[0] * s.x, p[1] * s.y, p[2] * s.z))
181 .collect();
182
183 let tri_indices: Vec<[u32; 3]> = indices
184 .chunks(3)
185 .filter(|c: &&[u32]| c.len() == 3)
186 .map(|c: &[u32]| [c[0], c[1], c[2]])
187 .collect();
188
189 if tri_indices.is_empty() {
190 continue;
191 }
192
193 match parry3d::shape::TriMesh::new(verts, tri_indices) {
194 Ok(trimesh) => {
195 let pose = Pose::from_parts(obj.position(), obj.rotation());
199 if let Some(intersection) =
200 trimesh.cast_ray_and_get_normal(&pose, &ray, f32::MAX, true)
201 {
202 let toi = intersection.time_of_impact;
203 if best_hit.is_none() || toi < best_hit.as_ref().unwrap().1 {
204 let sub_object = SubObjectRef::from_feature_id(intersection.feature);
205 let world_pos = ray_origin + ray_dir * toi;
206 let normal = intersection.normal;
208 let triangle_index = if let Some(SubObjectRef::Face(i)) = sub_object {
209 i
210 } else {
211 u32::MAX
212 };
213 #[allow(deprecated)]
214 let hit = PickHit {
215 id: obj.id(),
216 sub_object,
217 triangle_index,
218 world_pos,
219 normal,
220 point_index: None,
221 scalar_value: None,
222 };
223 best_hit = Some((obj.id(), toi, hit));
224 }
225 }
226 }
227 Err(e) => {
228 tracing::warn!(object_id = obj.id(), error = %e, "TriMesh construction failed for picking");
229 }
230 }
231 }
232 }
233
234 best_hit.map(|(_, _, hit)| hit)
235}
236
237pub fn pick_scene_nodes_cpu(
242 ray_origin: glam::Vec3,
243 ray_dir: glam::Vec3,
244 scene: &crate::scene::scene::Scene,
245 mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
246) -> Option<PickHit> {
247 if let Some(hit) = pick_light_glyphs_cpu(ray_origin, ray_dir, scene) {
251 return Some(hit);
252 }
253
254 let nodes: Vec<&dyn ViewportObject> = scene.nodes().map(|n| n as &dyn ViewportObject).collect();
255 pick_scene_cpu(ray_origin, ray_dir, &nodes, mesh_lookup)
256}
257
258const LIGHT_GLYPH_PICK_RADIUS: f32 = 0.35;
262
263fn pick_light_glyphs_cpu(
266 ray_origin: glam::Vec3,
267 ray_dir: glam::Vec3,
268 scene: &crate::scene::scene::Scene,
269) -> Option<PickHit> {
270 let mut best: Option<(f32, PickHit)> = None;
271 for node in scene.nodes() {
272 if node.light.is_none() {
273 continue;
274 }
275 if !node.is_visible() {
276 continue;
277 }
278 let world = node.world_transform();
279 let center = world.col(3).truncate();
280 let Some(toi) = ray_sphere_intersect(ray_origin, ray_dir, center, LIGHT_GLYPH_PICK_RADIUS)
281 else {
282 continue;
283 };
284 if best.as_ref().map(|(t, _)| toi < *t).unwrap_or(true) {
285 let world_pos = ray_origin + ray_dir * toi;
286 let normal = (world_pos - center).normalize_or_zero();
287 best = Some((toi, PickHit::object_hit(node.id(), world_pos, normal)));
288 }
289 }
290 best.map(|(_, h)| h)
291}
292
293fn ray_sphere_intersect(
296 origin: glam::Vec3,
297 dir: glam::Vec3,
298 center: glam::Vec3,
299 radius: f32,
300) -> Option<f32> {
301 let oc = origin - center;
302 let a = dir.dot(dir);
303 if a <= 0.0 {
304 return None;
305 }
306 let b = 2.0 * oc.dot(dir);
307 let c = oc.dot(oc) - radius * radius;
308 let disc = b * b - 4.0 * a * c;
309 if disc < 0.0 {
310 return None;
311 }
312 let sqd = disc.sqrt();
313 let t0 = (-b - sqd) / (2.0 * a);
314 let t1 = (-b + sqd) / (2.0 * a);
315 if t0 > 1.0e-4 {
316 Some(t0)
317 } else if t1 > 1.0e-4 {
318 Some(t1)
319 } else {
320 None
321 }
322}
323
324pub struct ProbeBinding<'a> {
333 pub id: u64,
335 pub attribute_ref: &'a AttributeRef,
337 pub attribute_data: &'a AttributeData,
339 pub positions: &'a [[f32; 3]],
341 pub indices: &'a [u32],
343}
344
345fn barycentric(p: glam::Vec3, a: glam::Vec3, b: glam::Vec3, c: glam::Vec3) -> (f32, f32, f32) {
350 let v0 = b - a;
351 let v1 = c - a;
352 let v2 = p - a;
353 let d00 = v0.dot(v0);
354 let d01 = v0.dot(v1);
355 let d11 = v1.dot(v1);
356 let d20 = v2.dot(v0);
357 let d21 = v2.dot(v1);
358 let denom = d00 * d11 - d01 * d01;
359 if denom.abs() < 1e-12 {
360 return (1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0);
362 }
363 let inv = 1.0 / denom;
364 let v = (d11 * d20 - d01 * d21) * inv;
365 let w = (d00 * d21 - d01 * d20) * inv;
366 let u = 1.0 - v - w;
367 (u, v, w)
368}
369
370fn probe_scalar(hit: &mut PickHit, binding: &ProbeBinding<'_>) {
373 let tri_idx_raw = match hit.sub_object {
374 Some(SubObjectRef::Face(i)) => i,
375 _ => return,
376 };
377
378 let num_triangles = binding.indices.len() / 3;
379 let tri_idx = if (tri_idx_raw as usize) >= num_triangles && num_triangles > 0 {
382 tri_idx_raw as usize - num_triangles
383 } else {
384 tri_idx_raw as usize
385 };
386
387 match binding.attribute_ref.kind {
388 AttributeKind::Cell => {
389 if let AttributeData::Cell(data) = binding.attribute_data {
391 if let Some(&val) = data.get(tri_idx) {
392 hit.scalar_value = Some(val);
393 }
394 }
395 }
396 AttributeKind::Face => {
397 if let AttributeData::Face(data) = binding.attribute_data {
399 if let Some(&val) = data.get(tri_idx) {
400 hit.scalar_value = Some(val);
401 }
402 }
403 }
404 AttributeKind::FaceColour => {
405 }
407 AttributeKind::Vertex => {
408 if let AttributeData::Vertex(data) = binding.attribute_data {
410 let base = tri_idx * 3;
411 if base + 2 >= binding.indices.len() {
412 return;
413 }
414 let i0 = binding.indices[base] as usize;
415 let i1 = binding.indices[base + 1] as usize;
416 let i2 = binding.indices[base + 2] as usize;
417
418 if i0 >= data.len() || i1 >= data.len() || i2 >= data.len() {
419 return;
420 }
421 if i0 >= binding.positions.len()
422 || i1 >= binding.positions.len()
423 || i2 >= binding.positions.len()
424 {
425 return;
426 }
427
428 let a = glam::Vec3::from(binding.positions[i0]);
429 let b = glam::Vec3::from(binding.positions[i1]);
430 let c = glam::Vec3::from(binding.positions[i2]);
431 let (u, v, w) = barycentric(hit.world_pos, a, b, c);
432 hit.scalar_value = Some(data[i0] * u + data[i1] * v + data[i2] * w);
433 }
434 }
435 AttributeKind::Edge => {
436 if let AttributeData::Edge(data) = binding.attribute_data {
439 let base = tri_idx * 3;
440 if base + 2 >= binding.indices.len() || data.is_empty() {
441 return;
442 }
443 let i0 = binding.indices[base] as usize;
444 let i1 = binding.indices[base + 1] as usize;
445 let i2 = binding.indices[base + 2] as usize;
446 if i0 < data.len() || i1 < data.len() || i2 < data.len() {
447 if i0 < data.len()
449 && i1 < data.len()
450 && i2 < data.len()
451 && i0 < binding.positions.len()
452 && i1 < binding.positions.len()
453 && i2 < binding.positions.len()
454 {
455 let a = glam::Vec3::from(binding.positions[i0]);
456 let b = glam::Vec3::from(binding.positions[i1]);
457 let c = glam::Vec3::from(binding.positions[i2]);
458 let (u, v, w) = barycentric(hit.world_pos, a, b, c);
459 hit.scalar_value = Some(data[i0] * u + data[i1] * v + data[i2] * w);
460 }
461 }
462 }
463 }
464 AttributeKind::Halfedge | AttributeKind::Corner => {
465 let extract = |data: &[f32]| -> Option<f32> {
468 let base = tri_idx * 3;
469 if base + 2 >= data.len() {
470 return None;
471 }
472 Some(data[base])
474 };
475 match binding.attribute_data {
476 AttributeData::Halfedge(data) | AttributeData::Corner(data) => {
477 hit.scalar_value = extract(data);
478 }
479 _ => {}
480 }
481 }
482 }
483}
484
485pub fn pick_scene_with_probe_cpu(
492 ray_origin: glam::Vec3,
493 ray_dir: glam::Vec3,
494 objects: &[&dyn ViewportObject],
495 mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
496 probe_bindings: &[ProbeBinding<'_>],
497) -> Option<PickHit> {
498 let mut hit = pick_scene_cpu(ray_origin, ray_dir, objects, mesh_lookup)?;
499 if let Some(binding) = probe_bindings.iter().find(|b| b.id == hit.id) {
500 probe_scalar(&mut hit, binding);
501 }
502 Some(hit)
503}
504
505pub fn pick_scene_nodes_with_probe_cpu(
509 ray_origin: glam::Vec3,
510 ray_dir: glam::Vec3,
511 scene: &crate::scene::scene::Scene,
512 mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
513 probe_bindings: &[ProbeBinding<'_>],
514) -> Option<PickHit> {
515 let mut hit = pick_scene_nodes_cpu(ray_origin, ray_dir, scene, mesh_lookup)?;
516 if let Some(binding) = probe_bindings.iter().find(|b| b.id == hit.id) {
517 probe_scalar(&mut hit, binding);
518 }
519 Some(hit)
520}
521
522pub fn pick_scene_accelerated_with_probe_cpu(
527 ray_origin: glam::Vec3,
528 ray_dir: glam::Vec3,
529 accelerator: &mut crate::geometry::bvh::PickAccelerator,
530 mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
531 probe_bindings: &[ProbeBinding<'_>],
532) -> Option<PickHit> {
533 let mut hit = accelerator.pick(ray_origin, ray_dir, mesh_lookup)?;
534 if let Some(binding) = probe_bindings.iter().find(|b| b.id == hit.id) {
535 probe_scalar(&mut hit, binding);
536 }
537 Some(hit)
538}
539
540#[derive(Clone, Debug, Default)]
551pub struct RectPickResult {
552 pub hits: std::collections::HashMap<u64, Vec<SubObjectRef>>,
559}
560
561impl RectPickResult {
562 pub fn is_empty(&self) -> bool {
564 self.hits.is_empty()
565 }
566
567 pub fn total_count(&self) -> usize {
569 self.hits.values().map(|v| v.len()).sum()
570 }
571}
572
573pub fn pick_rect(
591 rect_min: glam::Vec2,
592 rect_max: glam::Vec2,
593 scene_items: &[crate::renderer::SceneRenderItem],
594 mesh_lookup: &std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)>,
595 point_clouds: &[crate::renderer::PointCloudItem],
596 view_proj: glam::Mat4,
597 viewport_size: glam::Vec2,
598) -> RectPickResult {
599 let ndc_min = glam::Vec2::new(
602 rect_min.x / viewport_size.x * 2.0 - 1.0,
603 1.0 - rect_max.y / viewport_size.y * 2.0, );
605 let ndc_max = glam::Vec2::new(
606 rect_max.x / viewport_size.x * 2.0 - 1.0,
607 1.0 - rect_min.y / viewport_size.y * 2.0, );
609
610 let mut result = RectPickResult::default();
611
612 for item in scene_items {
614 if item.settings.hidden {
615 continue;
616 }
617 let Some((positions, indices)) = mesh_lookup.get(&item.mesh_id.index()) else {
618 continue;
619 };
620
621 let model = glam::Mat4::from_cols_array_2d(&item.model);
622 let mvp = view_proj * model;
623
624 let mut tri_hits: Vec<SubObjectRef> = Vec::new();
625
626 for (tri_idx, chunk) in indices.chunks(3).enumerate() {
627 if chunk.len() < 3 {
628 continue;
629 }
630 let i0 = chunk[0] as usize;
631 let i1 = chunk[1] as usize;
632 let i2 = chunk[2] as usize;
633
634 if i0 >= positions.len() || i1 >= positions.len() || i2 >= positions.len() {
635 continue;
636 }
637
638 let p0 = glam::Vec3::from(positions[i0]);
639 let p1 = glam::Vec3::from(positions[i1]);
640 let p2 = glam::Vec3::from(positions[i2]);
641 let centroid = (p0 + p1 + p2) / 3.0;
642
643 let clip = mvp * centroid.extend(1.0);
644 if clip.w <= 0.0 {
645 continue;
647 }
648 let ndc = glam::Vec2::new(clip.x / clip.w, clip.y / clip.w);
649
650 if ndc.x >= ndc_min.x && ndc.x <= ndc_max.x && ndc.y >= ndc_min.y && ndc.y <= ndc_max.y
651 {
652 tri_hits.push(SubObjectRef::Face(tri_idx as u32));
653 }
654 }
655
656 if !tri_hits.is_empty() {
657 result.hits.insert(item.settings.pick_id.0, tri_hits);
658 }
659 }
660
661 for pc in point_clouds {
663 if pc.settings.pick_id == crate::renderer::PickId::NONE {
664 continue;
666 }
667
668 let model = glam::Mat4::from_cols_array_2d(&pc.model);
669 let mvp = view_proj * model;
670
671 let mut pt_hits: Vec<SubObjectRef> = Vec::new();
672
673 for (pt_idx, pos) in pc.positions.iter().enumerate() {
674 let p = glam::Vec3::from(*pos);
675 let clip = mvp * p.extend(1.0);
676 if clip.w <= 0.0 {
677 continue;
678 }
679 let ndc = glam::Vec2::new(clip.x / clip.w, clip.y / clip.w);
680
681 if ndc.x >= ndc_min.x && ndc.x <= ndc_max.x && ndc.y >= ndc_min.y && ndc.y <= ndc_max.y
682 {
683 pt_hits.push(SubObjectRef::Point(pt_idx as u32));
684 }
685 }
686
687 if !pt_hits.is_empty() {
688 result.hits.insert(pc.settings.pick_id.0, pt_hits);
689 }
690 }
691
692 result
693}
694
695pub fn box_select(
704 rect_min: glam::Vec2,
705 rect_max: glam::Vec2,
706 objects: &[&dyn ViewportObject],
707 view_proj: glam::Mat4,
708 viewport_size: glam::Vec2,
709) -> Vec<u64> {
710 let mut hits = Vec::new();
711 for obj in objects {
712 if !obj.is_visible() {
713 continue;
714 }
715 let pos = obj.position();
716 let clip = view_proj * pos.extend(1.0);
717 if clip.w <= 0.0 {
719 continue;
720 }
721 let ndc = glam::Vec3::new(clip.x / clip.w, clip.y / clip.w, clip.z / clip.w);
722 let screen = glam::Vec2::new(
723 (ndc.x + 1.0) * 0.5 * viewport_size.x,
724 (1.0 - ndc.y) * 0.5 * viewport_size.y,
725 );
726 if screen.x >= rect_min.x
727 && screen.x <= rect_max.x
728 && screen.y >= rect_min.y
729 && screen.y <= rect_max.y
730 {
731 hits.push(obj.id());
732 }
733 }
734 hits
735}
736
737fn ray_aabb_volume(
748 origin: glam::Vec3,
749 dir: glam::Vec3,
750 bbox_min: glam::Vec3,
751 bbox_max: glam::Vec3,
752) -> Option<(f32, f32, usize, f32)> {
753 let mut t_min = f32::NEG_INFINITY;
754 let mut t_max = f32::INFINITY;
755 let mut entry_axis = 0usize;
756 let mut entry_sign = -1.0f32;
757
758 let dirs = [dir.x, dir.y, dir.z];
759 let origins = [origin.x, origin.y, origin.z];
760 let mins = [bbox_min.x, bbox_min.y, bbox_min.z];
761 let maxs = [bbox_max.x, bbox_max.y, bbox_max.z];
762
763 for i in 0..3 {
764 let d = dirs[i];
765 let o = origins[i];
766 if d.abs() < 1e-12 {
767 if o < mins[i] || o > maxs[i] {
769 return None;
770 }
771 } else {
772 let t1 = (mins[i] - o) / d;
773 let t2 = (maxs[i] - o) / d;
774 let (t_near, t_far) = if t1 <= t2 { (t1, t2) } else { (t2, t1) };
775 if t_near > t_min {
776 t_min = t_near;
777 entry_axis = i;
778 entry_sign = if d > 0.0 { -1.0 } else { 1.0 };
781 }
782 if t_far < t_max {
783 t_max = t_far;
784 }
785 }
786 }
787
788 if t_min > t_max || t_max < 0.0 {
789 return None;
790 }
791 Some((t_min, t_max, entry_axis, entry_sign))
792}
793
794pub fn pick_volume_cpu(
819 ray_origin: glam::Vec3,
820 ray_dir: glam::Vec3,
821 id: u64,
822 item: &crate::renderer::VolumeItem,
823 volume: &VolumeData,
824) -> Option<PickHit> {
825 let [nx, ny, nz] = volume.dims;
826 if nx == 0 || ny == 0 || nz == 0 || volume.data.is_empty() {
827 return None;
828 }
829
830 let model = glam::Mat4::from_cols_array_2d(&item.model);
832 let inv_model = model.inverse();
833 let local_origin = inv_model.transform_point3(ray_origin);
834 let local_dir = inv_model.transform_vector3(ray_dir);
835
836 let bbox_min = glam::Vec3::from(item.bbox_min);
837 let bbox_max = glam::Vec3::from(item.bbox_max);
838
839 let (t_entry, t_exit, entry_axis, entry_sign) =
840 ray_aabb_volume(local_origin, local_dir, bbox_min, bbox_max)?;
841
842 let t_start = t_entry.max(0.0);
844 if t_start >= t_exit {
845 return None;
846 }
847
848 let extent = bbox_max - bbox_min;
850 let cell = extent / glam::Vec3::new(nx as f32, ny as f32, nz as f32);
851
852 let p_entry = local_origin + t_start * local_dir;
854
855 let eps = 1e-4_f32;
858 let frac =
859 ((p_entry - bbox_min) / extent).clamp(glam::Vec3::splat(eps), glam::Vec3::splat(1.0 - eps));
860 let mut ix = (frac.x * nx as f32).floor() as i32;
861 let mut iy = (frac.y * ny as f32).floor() as i32;
862 let mut iz = (frac.z * nz as f32).floor() as i32;
863 ix = ix.clamp(0, nx as i32 - 1);
864 iy = iy.clamp(0, ny as i32 - 1);
865 iz = iz.clamp(0, nz as i32 - 1);
866
867 let step_x: i32 = if local_dir.x >= 0.0 { 1 } else { -1 };
869 let step_y: i32 = if local_dir.y >= 0.0 { 1 } else { -1 };
870 let step_z: i32 = if local_dir.z >= 0.0 { 1 } else { -1 };
871
872 let td_x = if local_dir.x.abs() > 1e-12 {
874 cell.x / local_dir.x.abs()
875 } else {
876 f32::INFINITY
877 };
878 let td_y = if local_dir.y.abs() > 1e-12 {
879 cell.y / local_dir.y.abs()
880 } else {
881 f32::INFINITY
882 };
883 let td_z = if local_dir.z.abs() > 1e-12 {
884 cell.z / local_dir.z.abs()
885 } else {
886 f32::INFINITY
887 };
888
889 let next_bx = bbox_min.x + (if step_x > 0 { ix + 1 } else { ix }) as f32 * cell.x;
891 let next_by = bbox_min.y + (if step_y > 0 { iy + 1 } else { iy }) as f32 * cell.y;
892 let next_bz = bbox_min.z + (if step_z > 0 { iz + 1 } else { iz }) as f32 * cell.z;
893
894 let mut tmax_x = if local_dir.x.abs() > 1e-12 {
895 t_start + (next_bx - p_entry.x) / local_dir.x
896 } else {
897 f32::INFINITY
898 };
899 let mut tmax_y = if local_dir.y.abs() > 1e-12 {
900 t_start + (next_by - p_entry.y) / local_dir.y
901 } else {
902 f32::INFINITY
903 };
904 let mut tmax_z = if local_dir.z.abs() > 1e-12 {
905 t_start + (next_bz - p_entry.z) / local_dir.z
906 } else {
907 f32::INFINITY
908 };
909
910 let mut entry_normal_local = glam::Vec3::ZERO;
912 match entry_axis {
913 0 => entry_normal_local.x = entry_sign,
914 1 => entry_normal_local.y = entry_sign,
915 _ => entry_normal_local.z = entry_sign,
916 }
917
918 let mut t_voxel_entry = t_start;
920
921 loop {
922 if ix < 0 || ix >= nx as i32 || iy < 0 || iy >= ny as i32 || iz < 0 || iz >= nz as i32 {
924 break;
925 }
926
927 let flat = ix as u32 + iy as u32 * nx + iz as u32 * nx * ny;
928 let scalar = volume.data[flat as usize];
929
930 if !scalar.is_nan() && scalar >= item.threshold_min && scalar <= item.threshold_max {
932 let local_hit = local_origin + t_voxel_entry * local_dir;
933 let world_pos = model.transform_point3(local_hit);
934 let world_normal = inv_model
936 .transpose()
937 .transform_vector3(entry_normal_local)
938 .normalize();
939
940 #[allow(deprecated)]
941 return Some(PickHit {
942 id,
943 sub_object: Some(SubObjectRef::Voxel(flat)),
944 world_pos,
945 normal: world_normal,
946 triangle_index: u32::MAX,
947 point_index: None,
948 scalar_value: Some(scalar),
949 });
950 }
951
952 if tmax_x <= tmax_y && tmax_x <= tmax_z {
954 if tmax_x > t_exit {
955 break;
956 }
957 t_voxel_entry = tmax_x;
958 tmax_x += td_x;
959 ix += step_x;
960 entry_normal_local = glam::Vec3::new(-(step_x as f32), 0.0, 0.0);
961 } else if tmax_y <= tmax_z {
962 if tmax_y > t_exit {
963 break;
964 }
965 t_voxel_entry = tmax_y;
966 tmax_y += td_y;
967 iy += step_y;
968 entry_normal_local = glam::Vec3::new(0.0, -(step_y as f32), 0.0);
969 } else {
970 if tmax_z > t_exit {
971 break;
972 }
973 t_voxel_entry = tmax_z;
974 tmax_z += td_z;
975 iz += step_z;
976 entry_normal_local = glam::Vec3::new(0.0, 0.0, -(step_z as f32));
977 }
978 }
979
980 None
981}
982
983pub fn voxel_world_aabb(
997 flat_index: u32,
998 volume: &VolumeData,
999 item: &crate::renderer::VolumeItem,
1000) -> (glam::Vec3, glam::Vec3) {
1001 let [nx, ny, nz] = volume.dims;
1002 let ix = flat_index % nx;
1003 let iy = (flat_index / nx) % ny;
1004 let iz = flat_index / (nx * ny);
1005 assert!(
1006 ix < nx && iy < ny && iz < nz,
1007 "flat_index {} out of bounds for dims {:?}",
1008 flat_index,
1009 volume.dims
1010 );
1011
1012 let bbox_min = glam::Vec3::from(item.bbox_min);
1013 let bbox_max = glam::Vec3::from(item.bbox_max);
1014 let cell = (bbox_max - bbox_min) / glam::Vec3::new(nx as f32, ny as f32, nz as f32);
1015
1016 let local_lo =
1017 bbox_min + glam::Vec3::new(ix as f32 * cell.x, iy as f32 * cell.y, iz as f32 * cell.z);
1018 let local_hi = local_lo + cell;
1019
1020 let model = glam::Mat4::from_cols_array_2d(&item.model);
1021 let corners = [
1022 glam::Vec3::new(local_lo.x, local_lo.y, local_lo.z),
1023 glam::Vec3::new(local_hi.x, local_lo.y, local_lo.z),
1024 glam::Vec3::new(local_lo.x, local_hi.y, local_lo.z),
1025 glam::Vec3::new(local_hi.x, local_hi.y, local_lo.z),
1026 glam::Vec3::new(local_lo.x, local_lo.y, local_hi.z),
1027 glam::Vec3::new(local_hi.x, local_lo.y, local_hi.z),
1028 glam::Vec3::new(local_lo.x, local_hi.y, local_hi.z),
1029 glam::Vec3::new(local_hi.x, local_hi.y, local_hi.z),
1030 ];
1031
1032 let world_min = corners
1033 .iter()
1034 .map(|&c| model.transform_point3(c))
1035 .fold(glam::Vec3::splat(f32::INFINITY), |acc, c| acc.min(c));
1036 let world_max = corners
1037 .iter()
1038 .map(|&c| model.transform_point3(c))
1039 .fold(glam::Vec3::splat(f32::NEG_INFINITY), |acc, c| acc.max(c));
1040
1041 (world_min, world_max)
1042}
1043
1044pub fn pick_point_cloud_cpu(
1058 click_pos: glam::Vec2,
1059 id: u64,
1060 item: &crate::renderer::PointCloudItem,
1061 view_proj: glam::Mat4,
1062 viewport_size: glam::Vec2,
1063 radius_px: f32,
1064) -> Option<PickHit> {
1065 if id == 0 || item.positions.is_empty() {
1066 return None;
1067 }
1068
1069 let model = glam::Mat4::from_cols_array_2d(&item.model);
1070 let mvp = view_proj * model;
1071
1072 let mut best_dist_sq = radius_px * radius_px;
1073 let mut best_idx: Option<u32> = None;
1074 let mut best_world = glam::Vec3::ZERO;
1075
1076 for (pt_idx, pos) in item.positions.iter().enumerate() {
1077 let local = glam::Vec3::from(*pos);
1078 let clip = mvp * local.extend(1.0);
1079 if clip.w <= 0.0 {
1080 continue;
1081 }
1082 let ndc_x = clip.x / clip.w;
1083 let ndc_y = clip.y / clip.w;
1084 let sx = (ndc_x + 1.0) * 0.5 * viewport_size.x;
1085 let sy = (1.0 - ndc_y) * 0.5 * viewport_size.y;
1086 let dx = sx - click_pos.x;
1087 let dy = sy - click_pos.y;
1088 let dist_sq = dx * dx + dy * dy;
1089 if dist_sq < best_dist_sq {
1090 best_dist_sq = dist_sq;
1091 best_idx = Some(pt_idx as u32);
1092 best_world = model.transform_point3(local);
1093 }
1094 }
1095
1096 let pt_idx = best_idx?;
1097 #[allow(deprecated)]
1098 Some(PickHit {
1099 id,
1100 sub_object: Some(SubObjectRef::Point(pt_idx)),
1101 world_pos: best_world,
1102 normal: glam::Vec3::Y,
1103 triangle_index: u32::MAX,
1104 point_index: Some(pt_idx),
1105 scalar_value: None,
1106 })
1107}
1108
1109pub fn nearest_vertex_on_hit(
1128 hit: &PickHit,
1129 positions: &[[f32; 3]],
1130 indices: &[u32],
1131 model: glam::Mat4,
1132) -> Option<SubObjectRef> {
1133 let face_raw = match hit.sub_object {
1134 Some(SubObjectRef::Face(i)) => i as usize,
1135 _ => return None,
1136 };
1137 let n_tri = indices.len() / 3;
1138 if n_tri == 0 {
1139 return None;
1140 }
1141 let face = if face_raw >= n_tri {
1143 face_raw - n_tri
1144 } else {
1145 face_raw
1146 };
1147 if face * 3 + 2 >= indices.len() {
1148 return None;
1149 }
1150 let vi = [
1151 indices[face * 3] as usize,
1152 indices[face * 3 + 1] as usize,
1153 indices[face * 3 + 2] as usize,
1154 ];
1155 let (best_vi, _) = vi
1156 .iter()
1157 .map(|&i| {
1158 let p = model.transform_point3(glam::Vec3::from(positions[i]));
1159 (i, p.distance(hit.world_pos))
1160 })
1161 .fold(
1162 (vi[0], f32::MAX),
1163 |acc, (i, d)| {
1164 if d < acc.1 { (i, d) } else { acc }
1165 },
1166 );
1167 Some(SubObjectRef::Vertex(best_vi as u32))
1168}
1169
1170pub fn pick_gaussian_splat_cpu(
1191 click_pos: glam::Vec2,
1192 id: u64,
1193 positions: &[[f32; 3]],
1194 model: glam::Mat4,
1195 view_proj: glam::Mat4,
1196 viewport_size: glam::Vec2,
1197 radius_px: f32,
1198) -> Option<PickHit> {
1199 if id == 0 || positions.is_empty() {
1200 return None;
1201 }
1202 let mvp = view_proj * model;
1203 let mut best_dist_sq = radius_px * radius_px;
1204 let mut best_idx: Option<u32> = None;
1205 let mut best_world = glam::Vec3::ZERO;
1206
1207 for (i, pos) in positions.iter().enumerate() {
1208 let local = glam::Vec3::from(*pos);
1209 let clip = mvp * local.extend(1.0);
1210 if clip.w <= 0.0 {
1211 continue;
1212 }
1213 let sx = (clip.x / clip.w + 1.0) * 0.5 * viewport_size.x;
1214 let sy = (1.0 - clip.y / clip.w) * 0.5 * viewport_size.y;
1215 let dx = sx - click_pos.x;
1216 let dy = sy - click_pos.y;
1217 let dist_sq = dx * dx + dy * dy;
1218 if dist_sq < best_dist_sq {
1219 best_dist_sq = dist_sq;
1220 best_idx = Some(i as u32);
1221 best_world = model.transform_point3(local);
1222 }
1223 }
1224
1225 let idx = best_idx?;
1226 #[allow(deprecated)]
1227 Some(PickHit {
1228 id,
1229 sub_object: Some(SubObjectRef::Point(idx)),
1230 world_pos: best_world,
1231 normal: glam::Vec3::Y,
1232 triangle_index: u32::MAX,
1233 point_index: Some(idx),
1234 scalar_value: None,
1235 })
1236}
1237
1238fn ray_tri_mt_ds(
1246 orig: glam::Vec3,
1247 dir: glam::Vec3,
1248 v0: glam::Vec3,
1249 v1: glam::Vec3,
1250 v2: glam::Vec3,
1251) -> Option<f32> {
1252 let e1 = v1 - v0;
1253 let e2 = v2 - v0;
1254 let h = dir.cross(e2);
1255 let a = e1.dot(h);
1256 if a.abs() < 1e-8 {
1257 return None;
1258 }
1259 let f = 1.0 / a;
1260 let s = orig - v0;
1261 let u = f * s.dot(h);
1262 if !(0.0..=1.0).contains(&u) {
1263 return None;
1264 }
1265 let q = s.cross(e1);
1266 let v = f * dir.dot(q);
1267 if v < 0.0 || u + v > 1.0 {
1268 return None;
1269 }
1270 let t = f * e2.dot(q);
1271 if t > 1e-6 { Some(t) } else { None }
1272}
1273
1274const VM_TET_FACES: [[usize; 3]; 4] = [[1, 2, 3], [0, 3, 2], [0, 1, 3], [0, 2, 1]];
1279
1280const VM_HEX_TRIS: [[usize; 3]; 12] = [
1282 [0, 1, 2],
1283 [0, 2, 3], [4, 7, 6],
1285 [4, 6, 5], [0, 4, 5],
1287 [0, 5, 1], [2, 6, 7],
1289 [2, 7, 3], [0, 3, 7],
1291 [0, 7, 4], [1, 5, 6],
1293 [1, 6, 2], ];
1295
1296const VM_PYRAMID_TRIS: [[usize; 3]; 6] = [
1298 [0, 1, 2],
1299 [0, 2, 3], [0, 4, 1],
1301 [1, 4, 2],
1302 [2, 4, 3],
1303 [3, 4, 0], ];
1305
1306const VM_WEDGE_TRIS: [[usize; 3]; 8] = [
1308 [0, 2, 1],
1309 [3, 4, 5], [0, 1, 4],
1311 [0, 4, 3], [1, 2, 5],
1313 [1, 5, 4], [2, 0, 3],
1315 [2, 3, 5], ];
1317
1318pub fn pick_transparent_volume_mesh_cpu(
1333 ray_origin: glam::Vec3,
1334 ray_dir: glam::Vec3,
1335 id: u64,
1336 model: glam::Mat4,
1337 data: &VolumeMeshData,
1338) -> Option<PickHit> {
1339 if id == 0 || data.cells.is_empty() {
1340 return None;
1341 }
1342 let model_inv = model.inverse();
1343 let local_origin = model_inv.transform_point3(ray_origin);
1344 let local_dir = model_inv.transform_vector3(ray_dir);
1345
1346 let mut best_t = f32::MAX;
1347 let mut best_cell: Option<u32> = None;
1348
1349 for (cell_idx, cell) in data.cells.iter().enumerate() {
1350 let p = |i: usize| glam::Vec3::from(data.positions[cell[i] as usize]);
1351 let tris: &[[usize; 3]] = if cell[4] == CELL_SENTINEL {
1352 &VM_TET_FACES
1353 } else if cell[5] == CELL_SENTINEL {
1354 &VM_PYRAMID_TRIS
1355 } else if cell[6] == CELL_SENTINEL {
1356 &VM_WEDGE_TRIS
1357 } else {
1358 &VM_HEX_TRIS
1359 };
1360 for tri in tris {
1361 if let Some(t) = ray_tri_mt_ds(local_origin, local_dir, p(tri[0]), p(tri[1]), p(tri[2]))
1362 {
1363 if t < best_t {
1364 best_t = t;
1365 best_cell = Some(cell_idx as u32);
1366 }
1367 }
1368 }
1369 }
1370
1371 let cell_idx = best_cell?;
1372 let local_hit = local_origin + local_dir * best_t;
1373 let world_hit = model.transform_point3(local_hit);
1374 #[allow(deprecated)]
1375 Some(PickHit {
1376 id,
1377 sub_object: Some(SubObjectRef::Cell(cell_idx)),
1378 world_pos: world_hit,
1379 normal: -ray_dir.normalize(),
1380 triangle_index: u32::MAX,
1381 point_index: None,
1382 scalar_value: None,
1383 })
1384}
1385
1386pub fn pick_volume_rect(
1406 rect_min: glam::Vec2,
1407 rect_max: glam::Vec2,
1408 id: u64,
1409 item: &crate::renderer::VolumeItem,
1410 volume: &VolumeData,
1411 view_proj: glam::Mat4,
1412 viewport_size: glam::Vec2,
1413) -> RectPickResult {
1414 let mut result = RectPickResult::default();
1415 if id == 0 {
1416 return result;
1417 }
1418 let model = glam::Mat4::from_cols_array_2d(&item.model);
1419 let bbox_min = glam::Vec3::from(item.bbox_min);
1420 let bbox_max = glam::Vec3::from(item.bbox_max);
1421 let [nx, ny, nz] = volume.dims;
1422 let cell = (bbox_max - bbox_min) / glam::Vec3::new(nx as f32, ny as f32, nz as f32);
1423 let mvp = view_proj * model;
1424
1425 let mut hits: Vec<SubObjectRef> = Vec::new();
1426 for iz in 0..nz {
1427 for iy in 0..ny {
1428 for ix in 0..nx {
1429 let flat = ix + iy * nx + iz * nx * ny;
1430 let scalar = volume.data[flat as usize];
1431 if scalar.is_nan() || scalar < item.threshold_min || scalar > item.threshold_max {
1432 continue;
1433 }
1434 let local_center = bbox_min
1435 + cell * glam::Vec3::new(ix as f32 + 0.5, iy as f32 + 0.5, iz as f32 + 0.5);
1436 let clip = mvp * local_center.extend(1.0);
1437 if clip.w <= 0.0 {
1438 continue;
1439 }
1440 let sx = (clip.x / clip.w + 1.0) * 0.5 * viewport_size.x;
1441 let sy = (1.0 - clip.y / clip.w) * 0.5 * viewport_size.y;
1442 if sx >= rect_min.x && sx <= rect_max.x && sy >= rect_min.y && sy <= rect_max.y {
1443 hits.push(SubObjectRef::Voxel(flat));
1444 }
1445 }
1446 }
1447 }
1448 if !hits.is_empty() {
1449 result.hits.insert(id, hits);
1450 }
1451 result
1452}
1453
1454pub fn pick_transparent_volume_mesh_rect(
1473 rect_min: glam::Vec2,
1474 rect_max: glam::Vec2,
1475 id: u64,
1476 model: glam::Mat4,
1477 data: &VolumeMeshData,
1478 view_proj: glam::Mat4,
1479 viewport_size: glam::Vec2,
1480) -> RectPickResult {
1481 let mut result = RectPickResult::default();
1482 if id == 0 || data.cells.is_empty() {
1483 return result;
1484 }
1485 let mvp = view_proj * model;
1486 let mut hits: Vec<SubObjectRef> = Vec::new();
1487
1488 for (cell_idx, cell) in data.cells.iter().enumerate() {
1489 let nv: usize = if cell[4] == CELL_SENTINEL {
1490 4
1491 } else if cell[5] == CELL_SENTINEL {
1492 5
1493 } else if cell[6] == CELL_SENTINEL {
1494 6
1495 } else {
1496 8
1497 };
1498 let centroid: glam::Vec3 = cell[..nv]
1499 .iter()
1500 .map(|&vi| glam::Vec3::from(data.positions[vi as usize]))
1501 .sum::<glam::Vec3>()
1502 / nv as f32;
1503 let clip = mvp * centroid.extend(1.0);
1504 if clip.w <= 0.0 {
1505 continue;
1506 }
1507 let sx = (clip.x / clip.w + 1.0) * 0.5 * viewport_size.x;
1508 let sy = (1.0 - clip.y / clip.w) * 0.5 * viewport_size.y;
1509 if sx >= rect_min.x && sx <= rect_max.x && sy >= rect_min.y && sy <= rect_max.y {
1510 hits.push(SubObjectRef::Cell(cell_idx as u32));
1511 }
1512 }
1513 if !hits.is_empty() {
1514 result.hits.insert(id, hits);
1515 }
1516 result
1517}
1518
1519pub fn pick_gaussian_splat_rect(
1538 rect_min: glam::Vec2,
1539 rect_max: glam::Vec2,
1540 id: u64,
1541 positions: &[[f32; 3]],
1542 model: glam::Mat4,
1543 view_proj: glam::Mat4,
1544 viewport_size: glam::Vec2,
1545) -> RectPickResult {
1546 let mut result = RectPickResult::default();
1547 if id == 0 || positions.is_empty() {
1548 return result;
1549 }
1550 let mvp = view_proj * model;
1551 let mut hits: Vec<SubObjectRef> = Vec::new();
1552
1553 for (i, pos) in positions.iter().enumerate() {
1554 let local = glam::Vec3::from(*pos);
1555 let clip = mvp * local.extend(1.0);
1556 if clip.w <= 0.0 {
1557 continue;
1558 }
1559 let sx = (clip.x / clip.w + 1.0) * 0.5 * viewport_size.x;
1560 let sy = (1.0 - clip.y / clip.w) * 0.5 * viewport_size.y;
1561 if sx >= rect_min.x && sx <= rect_max.x && sy >= rect_min.y && sy <= rect_max.y {
1562 hits.push(SubObjectRef::Point(i as u32));
1563 }
1564 }
1565 if !hits.is_empty() {
1566 result.hits.insert(id, hits);
1567 }
1568 result
1569}
1570
1571#[cfg(test)]
1572mod tests {
1573 use super::*;
1574 use crate::scene::traits::ViewportObject;
1575 use std::collections::HashMap;
1576
1577 struct TestObject {
1578 id: u64,
1579 mesh_id: u64,
1580 position: glam::Vec3,
1581 visible: bool,
1582 }
1583
1584 impl ViewportObject for TestObject {
1585 fn id(&self) -> u64 {
1586 self.id
1587 }
1588 fn mesh_id(&self) -> Option<u64> {
1589 Some(self.mesh_id)
1590 }
1591 fn model_matrix(&self) -> glam::Mat4 {
1592 glam::Mat4::from_translation(self.position)
1593 }
1594 fn position(&self) -> glam::Vec3 {
1595 self.position
1596 }
1597 fn rotation(&self) -> glam::Quat {
1598 glam::Quat::IDENTITY
1599 }
1600 fn is_visible(&self) -> bool {
1601 self.visible
1602 }
1603 fn colour(&self) -> glam::Vec3 {
1604 glam::Vec3::ONE
1605 }
1606 }
1607
1608 fn unit_cube_mesh() -> (Vec<[f32; 3]>, Vec<u32>) {
1610 let positions = vec![
1611 [-0.5, -0.5, -0.5],
1612 [0.5, -0.5, -0.5],
1613 [0.5, 0.5, -0.5],
1614 [-0.5, 0.5, -0.5],
1615 [-0.5, -0.5, 0.5],
1616 [0.5, -0.5, 0.5],
1617 [0.5, 0.5, 0.5],
1618 [-0.5, 0.5, 0.5],
1619 ];
1620 let indices = vec![
1621 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, ];
1628 (positions, indices)
1629 }
1630
1631 #[test]
1632 fn test_screen_to_ray_center() {
1633 let vp_inv = glam::Mat4::IDENTITY;
1635 let (origin, dir) = screen_to_ray(
1636 glam::Vec2::new(400.0, 300.0),
1637 glam::Vec2::new(800.0, 600.0),
1638 vp_inv,
1639 );
1640 assert!(origin.x.abs() < 1e-3, "origin.x={}", origin.x);
1642 assert!(origin.y.abs() < 1e-3, "origin.y={}", origin.y);
1643 assert!(dir.z.abs() > 0.9, "dir should be along Z, got {dir:?}");
1644 }
1645
1646 #[test]
1647 fn test_pick_scene_hit() {
1648 let (positions, indices) = unit_cube_mesh();
1649 let mut mesh_lookup = HashMap::new();
1650 mesh_lookup.insert(1u64, (positions, indices));
1651
1652 let obj = TestObject {
1653 id: 42,
1654 mesh_id: 1,
1655 position: glam::Vec3::ZERO,
1656 visible: true,
1657 };
1658 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1659
1660 let result = pick_scene_cpu(
1662 glam::Vec3::new(0.0, 0.0, 5.0),
1663 glam::Vec3::new(0.0, 0.0, -1.0),
1664 &objects,
1665 &mesh_lookup,
1666 );
1667 assert!(result.is_some(), "expected a hit");
1668 let hit = result.unwrap();
1669 assert_eq!(hit.id, 42);
1670 assert!(
1672 (hit.world_pos.z - 0.5).abs() < 0.01,
1673 "world_pos.z={}",
1674 hit.world_pos.z
1675 );
1676 assert!(hit.normal.z > 0.9, "normal={:?}", hit.normal);
1678 }
1679
1680 #[test]
1681 fn test_pick_scene_miss() {
1682 let (positions, indices) = unit_cube_mesh();
1683 let mut mesh_lookup = HashMap::new();
1684 mesh_lookup.insert(1u64, (positions, indices));
1685
1686 let obj = TestObject {
1687 id: 42,
1688 mesh_id: 1,
1689 position: glam::Vec3::ZERO,
1690 visible: true,
1691 };
1692 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1693
1694 let result = pick_scene_cpu(
1696 glam::Vec3::new(100.0, 100.0, 5.0),
1697 glam::Vec3::new(0.0, 0.0, -1.0),
1698 &objects,
1699 &mesh_lookup,
1700 );
1701 assert!(result.is_none());
1702 }
1703
1704 #[test]
1705 fn test_pick_nearest_wins() {
1706 let (positions, indices) = unit_cube_mesh();
1707 let mut mesh_lookup = HashMap::new();
1708 mesh_lookup.insert(1u64, (positions.clone(), indices.clone()));
1709 mesh_lookup.insert(2u64, (positions, indices));
1710
1711 let near_obj = TestObject {
1712 id: 10,
1713 mesh_id: 1,
1714 position: glam::Vec3::new(0.0, 0.0, 2.0),
1715 visible: true,
1716 };
1717 let far_obj = TestObject {
1718 id: 20,
1719 mesh_id: 2,
1720 position: glam::Vec3::new(0.0, 0.0, -2.0),
1721 visible: true,
1722 };
1723 let objects: Vec<&dyn ViewportObject> = vec![&far_obj, &near_obj];
1724
1725 let result = pick_scene_cpu(
1727 glam::Vec3::new(0.0, 0.0, 10.0),
1728 glam::Vec3::new(0.0, 0.0, -1.0),
1729 &objects,
1730 &mesh_lookup,
1731 );
1732 assert!(result.is_some(), "expected a hit");
1733 assert_eq!(result.unwrap().id, 10);
1734 }
1735
1736 #[test]
1737 fn test_box_select_hits_inside_rect() {
1738 let view = glam::Mat4::look_at_rh(
1740 glam::Vec3::new(0.0, 0.0, 5.0),
1741 glam::Vec3::ZERO,
1742 glam::Vec3::Y,
1743 );
1744 let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
1745 let vp = proj * view;
1746 let viewport_size = glam::Vec2::new(800.0, 600.0);
1747
1748 let obj = TestObject {
1749 id: 42,
1750 mesh_id: 1,
1751 position: glam::Vec3::ZERO,
1752 visible: true,
1753 };
1754 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1755
1756 let result = box_select(
1758 glam::Vec2::new(300.0, 200.0),
1759 glam::Vec2::new(500.0, 400.0),
1760 &objects,
1761 vp,
1762 viewport_size,
1763 );
1764 assert_eq!(result, vec![42]);
1765 }
1766
1767 #[test]
1768 fn test_box_select_skips_hidden() {
1769 let view = glam::Mat4::look_at_rh(
1770 glam::Vec3::new(0.0, 0.0, 5.0),
1771 glam::Vec3::ZERO,
1772 glam::Vec3::Y,
1773 );
1774 let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
1775 let vp = proj * view;
1776 let viewport_size = glam::Vec2::new(800.0, 600.0);
1777
1778 let obj = TestObject {
1779 id: 42,
1780 mesh_id: 1,
1781 position: glam::Vec3::ZERO,
1782 visible: false,
1783 };
1784 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1785
1786 let result = box_select(
1787 glam::Vec2::new(0.0, 0.0),
1788 glam::Vec2::new(800.0, 600.0),
1789 &objects,
1790 vp,
1791 viewport_size,
1792 );
1793 assert!(result.is_empty());
1794 }
1795
1796 #[test]
1797 fn test_pick_scene_nodes_hit() {
1798 let (positions, indices) = unit_cube_mesh();
1799 let mut mesh_lookup = HashMap::new();
1800 mesh_lookup.insert(0u64, (positions, indices));
1801
1802 let mut scene = crate::scene::scene::Scene::new();
1803 scene.add(
1804 Some(crate::resources::mesh_store::MeshId(0)),
1805 glam::Mat4::IDENTITY,
1806 crate::scene::material::Material::default(),
1807 );
1808 scene.update_transforms();
1809
1810 let result = pick_scene_nodes_cpu(
1811 glam::Vec3::new(0.0, 0.0, 5.0),
1812 glam::Vec3::new(0.0, 0.0, -1.0),
1813 &scene,
1814 &mesh_lookup,
1815 );
1816 assert!(result.is_some());
1817 }
1818
1819 #[test]
1820 fn test_pick_scene_nodes_miss() {
1821 let (positions, indices) = unit_cube_mesh();
1822 let mut mesh_lookup = HashMap::new();
1823 mesh_lookup.insert(0u64, (positions, indices));
1824
1825 let mut scene = crate::scene::scene::Scene::new();
1826 scene.add(
1827 Some(crate::resources::mesh_store::MeshId(0)),
1828 glam::Mat4::IDENTITY,
1829 crate::scene::material::Material::default(),
1830 );
1831 scene.update_transforms();
1832
1833 let result = pick_scene_nodes_cpu(
1834 glam::Vec3::new(100.0, 100.0, 5.0),
1835 glam::Vec3::new(0.0, 0.0, -1.0),
1836 &scene,
1837 &mesh_lookup,
1838 );
1839 assert!(result.is_none());
1840 }
1841
1842 #[test]
1843 fn test_probe_vertex_attribute() {
1844 let (positions, indices) = unit_cube_mesh();
1845 let mut mesh_lookup = HashMap::new();
1846 mesh_lookup.insert(1u64, (positions.clone(), indices.clone()));
1847
1848 let vertex_scalars: Vec<f32> = (0..positions.len()).map(|i| i as f32).collect();
1850
1851 let obj = TestObject {
1852 id: 42,
1853 mesh_id: 1,
1854 position: glam::Vec3::ZERO,
1855 visible: true,
1856 };
1857 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1858
1859 let attr_ref = AttributeRef {
1860 name: "test".to_string(),
1861 kind: AttributeKind::Vertex,
1862 };
1863 let attr_data = AttributeData::Vertex(vertex_scalars);
1864 let bindings = vec![ProbeBinding {
1865 id: 42,
1866 attribute_ref: &attr_ref,
1867 attribute_data: &attr_data,
1868 positions: &positions,
1869 indices: &indices,
1870 }];
1871
1872 let result = pick_scene_with_probe_cpu(
1873 glam::Vec3::new(0.0, 0.0, 5.0),
1874 glam::Vec3::new(0.0, 0.0, -1.0),
1875 &objects,
1876 &mesh_lookup,
1877 &bindings,
1878 );
1879 assert!(result.is_some(), "expected a hit");
1880 let hit = result.unwrap();
1881 assert_eq!(hit.id, 42);
1882 assert!(
1884 hit.scalar_value.is_some(),
1885 "expected scalar_value to be set"
1886 );
1887 }
1888
1889 #[test]
1890 fn test_probe_cell_attribute() {
1891 let (positions, indices) = unit_cube_mesh();
1892 let mut mesh_lookup = HashMap::new();
1893 mesh_lookup.insert(1u64, (positions.clone(), indices.clone()));
1894
1895 let num_triangles = indices.len() / 3;
1897 let cell_scalars: Vec<f32> = (0..num_triangles).map(|i| (i as f32) * 10.0).collect();
1898
1899 let obj = TestObject {
1900 id: 42,
1901 mesh_id: 1,
1902 position: glam::Vec3::ZERO,
1903 visible: true,
1904 };
1905 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1906
1907 let attr_ref = AttributeRef {
1908 name: "pressure".to_string(),
1909 kind: AttributeKind::Cell,
1910 };
1911 let attr_data = AttributeData::Cell(cell_scalars.clone());
1912 let bindings = vec![ProbeBinding {
1913 id: 42,
1914 attribute_ref: &attr_ref,
1915 attribute_data: &attr_data,
1916 positions: &positions,
1917 indices: &indices,
1918 }];
1919
1920 let result = pick_scene_with_probe_cpu(
1921 glam::Vec3::new(0.0, 0.0, 5.0),
1922 glam::Vec3::new(0.0, 0.0, -1.0),
1923 &objects,
1924 &mesh_lookup,
1925 &bindings,
1926 );
1927 assert!(result.is_some());
1928 let hit = result.unwrap();
1929 assert!(hit.scalar_value.is_some());
1931 let val = hit.scalar_value.unwrap();
1932 assert!(
1933 cell_scalars.contains(&val),
1934 "scalar_value {val} not in cell_scalars"
1935 );
1936 }
1937
1938 #[test]
1939 fn test_probe_no_binding_leaves_none() {
1940 let (positions, indices) = unit_cube_mesh();
1941 let mut mesh_lookup = HashMap::new();
1942 mesh_lookup.insert(1u64, (positions, indices));
1943
1944 let obj = TestObject {
1945 id: 42,
1946 mesh_id: 1,
1947 position: glam::Vec3::ZERO,
1948 visible: true,
1949 };
1950 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1951
1952 let result = pick_scene_with_probe_cpu(
1954 glam::Vec3::new(0.0, 0.0, 5.0),
1955 glam::Vec3::new(0.0, 0.0, -1.0),
1956 &objects,
1957 &mesh_lookup,
1958 &[],
1959 );
1960 assert!(result.is_some());
1961 assert!(result.unwrap().scalar_value.is_none());
1962 }
1963
1964 fn make_view_proj() -> glam::Mat4 {
1970 let view = glam::Mat4::look_at_rh(
1971 glam::Vec3::new(0.0, 0.0, 5.0),
1972 glam::Vec3::ZERO,
1973 glam::Vec3::Y,
1974 );
1975 let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
1976 proj * view
1977 }
1978
1979 #[test]
1980 fn test_pick_rect_mesh_full_screen() {
1981 let (positions, indices) = unit_cube_mesh();
1983 let mut mesh_lookup: std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)> =
1984 std::collections::HashMap::new();
1985 mesh_lookup.insert(0, (positions, indices.clone()));
1986
1987 let item = crate::renderer::SceneRenderItem {
1988 mesh_id: crate::resources::mesh_store::MeshId(0),
1989 model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1990 ..Default::default()
1991 };
1992
1993 let view_proj = make_view_proj();
1994 let viewport = glam::Vec2::new(800.0, 600.0);
1995
1996 let result = pick_rect(
1997 glam::Vec2::ZERO,
1998 viewport,
1999 &[item],
2000 &mesh_lookup,
2001 &[],
2002 view_proj,
2003 viewport,
2004 );
2005
2006 assert!(!result.is_empty(), "expected at least one triangle hit");
2008 assert!(result.total_count() > 0);
2009 }
2010
2011 #[test]
2012 fn test_pick_rect_miss() {
2013 let (positions, indices) = unit_cube_mesh();
2015 let mut mesh_lookup: std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)> =
2016 std::collections::HashMap::new();
2017 mesh_lookup.insert(0, (positions, indices));
2018
2019 let item = crate::renderer::SceneRenderItem {
2020 mesh_id: crate::resources::mesh_store::MeshId(0),
2021 model: glam::Mat4::IDENTITY.to_cols_array_2d(),
2022 ..Default::default()
2023 };
2024
2025 let view_proj = make_view_proj();
2026 let viewport = glam::Vec2::new(800.0, 600.0);
2027
2028 let result = pick_rect(
2029 glam::Vec2::new(700.0, 500.0), glam::Vec2::new(799.0, 599.0),
2031 &[item],
2032 &mesh_lookup,
2033 &[],
2034 view_proj,
2035 viewport,
2036 );
2037
2038 assert!(result.is_empty(), "expected no hits in off-center rect");
2039 }
2040
2041 #[test]
2042 fn test_pick_rect_point_cloud() {
2043 let view_proj = make_view_proj();
2045 let viewport = glam::Vec2::new(800.0, 600.0);
2046
2047 let pc = crate::renderer::PointCloudItem {
2048 positions: vec![[0.0, 0.0, 0.0], [0.1, 0.1, 0.0]],
2049 model: glam::Mat4::IDENTITY.to_cols_array_2d(),
2050 settings: crate::scene::material::ItemSettings {
2051 pick_id: crate::renderer::PickId(99),
2052 ..Default::default()
2053 },
2054 ..Default::default()
2055 };
2056
2057 let result = pick_rect(
2058 glam::Vec2::ZERO,
2059 viewport,
2060 &[],
2061 &std::collections::HashMap::new(),
2062 &[pc],
2063 view_proj,
2064 viewport,
2065 );
2066
2067 assert!(!result.is_empty(), "expected point cloud hits");
2068 let hits = result.hits.get(&99).expect("expected hits for id 99");
2069 assert_eq!(
2070 hits.len(),
2071 2,
2072 "both points should be inside the full-screen rect"
2073 );
2074 assert!(
2076 hits.iter().all(|s| s.is_point()),
2077 "expected SubObjectRef::Point entries"
2078 );
2079 assert_eq!(hits[0], SubObjectRef::Point(0));
2080 assert_eq!(hits[1], SubObjectRef::Point(1));
2081 }
2082
2083 #[test]
2084 fn test_pick_rect_skips_invisible() {
2085 let (positions, indices) = unit_cube_mesh();
2086 let mut mesh_lookup: std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)> =
2087 std::collections::HashMap::new();
2088 mesh_lookup.insert(0, (positions, indices));
2089
2090 let item = crate::renderer::SceneRenderItem {
2091 mesh_id: crate::resources::mesh_store::MeshId(0),
2092 model: glam::Mat4::IDENTITY.to_cols_array_2d(),
2093 settings: crate::scene::material::ItemSettings {
2094 hidden: true,
2095 ..Default::default()
2096 },
2097 ..Default::default()
2098 };
2099
2100 let view_proj = make_view_proj();
2101 let viewport = glam::Vec2::new(800.0, 600.0);
2102
2103 let result = pick_rect(
2104 glam::Vec2::ZERO,
2105 viewport,
2106 &[item],
2107 &mesh_lookup,
2108 &[],
2109 view_proj,
2110 viewport,
2111 );
2112
2113 assert!(result.is_empty(), "invisible items should be skipped");
2114 }
2115
2116 #[test]
2117 fn test_pick_rect_result_type() {
2118 let mut r = RectPickResult::default();
2120 assert!(r.is_empty());
2121 assert_eq!(r.total_count(), 0);
2122
2123 r.hits.insert(
2124 1,
2125 vec![
2126 SubObjectRef::Face(0),
2127 SubObjectRef::Face(1),
2128 SubObjectRef::Face(2),
2129 ],
2130 );
2131 r.hits.insert(2, vec![SubObjectRef::Point(5)]);
2132 assert!(!r.is_empty());
2133 assert_eq!(r.total_count(), 4);
2134 }
2135
2136 #[test]
2137 fn test_barycentric_at_vertices() {
2138 let a = glam::Vec3::new(0.0, 0.0, 0.0);
2139 let b = glam::Vec3::new(1.0, 0.0, 0.0);
2140 let c = glam::Vec3::new(0.0, 1.0, 0.0);
2141
2142 let (u, v, w) = super::barycentric(a, a, b, c);
2144 assert!((u - 1.0).abs() < 1e-5, "u={u}");
2145 assert!(v.abs() < 1e-5, "v={v}");
2146 assert!(w.abs() < 1e-5, "w={w}");
2147
2148 let (u, v, w) = super::barycentric(b, a, b, c);
2150 assert!(u.abs() < 1e-5, "u={u}");
2151 assert!((v - 1.0).abs() < 1e-5, "v={v}");
2152 assert!(w.abs() < 1e-5, "w={w}");
2153
2154 let centroid = (a + b + c) / 3.0;
2156 let (u, v, w) = super::barycentric(centroid, a, b, c);
2157 assert!((u - 1.0 / 3.0).abs() < 1e-4, "u={u}");
2158 assert!((v - 1.0 / 3.0).abs() < 1e-4, "v={v}");
2159 assert!((w - 1.0 / 3.0).abs() < 1e-4, "w={w}");
2160 }
2161
2162 fn make_volume_item(
2167 bbox_min: [f32; 3],
2168 bbox_max: [f32; 3],
2169 threshold_min: f32,
2170 threshold_max: f32,
2171 ) -> crate::renderer::VolumeItem {
2172 crate::renderer::VolumeItem {
2173 bbox_min,
2174 bbox_max,
2175 threshold_min,
2176 threshold_max,
2177 ..crate::renderer::VolumeItem::default()
2178 }
2179 }
2180
2181 fn make_volume_data(dims: [u32; 3], fill: f32) -> crate::geometry::marching_cubes::VolumeData {
2182 let n = (dims[0] * dims[1] * dims[2]) as usize;
2183 crate::geometry::marching_cubes::VolumeData {
2184 data: vec![fill; n],
2185 dims,
2186 origin: [0.0; 3],
2187 spacing: [1.0; 3],
2188 }
2189 }
2190
2191 #[test]
2192 fn test_pick_volume_basic_hit() {
2193 let item = make_volume_item([0.0; 3], [3.0, 3.0, 3.0], 0.5, 1.0);
2196 let volume = make_volume_data([3, 3, 3], 0.8);
2197
2198 let hit = super::pick_volume_cpu(
2199 glam::Vec3::new(1.5, 10.0, 1.5),
2200 glam::Vec3::new(0.0, -1.0, 0.0),
2201 42,
2202 &item,
2203 &volume,
2204 );
2205 assert!(hit.is_some(), "expected a hit");
2206 let hit = hit.unwrap();
2207
2208 assert_eq!(hit.id, 42);
2209 assert_eq!(hit.scalar_value, Some(0.8));
2210
2211 let flat = hit.sub_object.unwrap().index();
2213 let nx = 3u32;
2214 let ny = 3u32;
2215 let ix = flat % nx;
2216 let iy = (flat / nx) % ny;
2217 let iz = flat / (nx * ny);
2218 assert_eq!((ix, iy, iz), (1, 2, 1), "expected top-centre voxel");
2219
2220 assert!(hit.world_pos.y > 2.9, "world_pos.y={}", hit.world_pos.y);
2222
2223 assert!(hit.normal.y > 0.9, "normal={:?}", hit.normal);
2225 }
2226
2227 #[test]
2228 fn test_pick_volume_miss_aabb() {
2229 let item = make_volume_item([0.0; 3], [1.0; 3], 0.0, 1.0);
2230 let volume = make_volume_data([4, 4, 4], 0.5);
2231
2232 let hit = super::pick_volume_cpu(
2234 glam::Vec3::new(10.0, 5.0, 0.5),
2235 glam::Vec3::new(0.0, -1.0, 0.0),
2236 1,
2237 &item,
2238 &volume,
2239 );
2240 assert!(hit.is_none(), "expected miss");
2241 }
2242
2243 #[test]
2244 fn test_pick_volume_threshold_miss() {
2245 let item = make_volume_item([0.0; 3], [1.0; 3], 0.5, 1.0);
2247 let volume = make_volume_data([4, 4, 4], 0.3);
2248
2249 let hit = super::pick_volume_cpu(
2250 glam::Vec3::new(0.5, 5.0, 0.5),
2251 glam::Vec3::new(0.0, -1.0, 0.0),
2252 1,
2253 &item,
2254 &volume,
2255 );
2256 assert!(
2257 hit.is_none(),
2258 "expected no hit when all scalars below threshold"
2259 );
2260 }
2261
2262 #[test]
2263 fn test_pick_volume_threshold_skip() {
2264 let item = make_volume_item([0.0; 3], [1.0, 3.0, 1.0], 0.5, 1.0);
2269 let mut volume = make_volume_data([1, 3, 1], 0.0);
2270 volume.data[2] = 0.3;
2272 volume.data[1] = 0.8;
2273 volume.data[0] = 0.8;
2274
2275 let hit = super::pick_volume_cpu(
2276 glam::Vec3::new(0.5, 10.0, 0.5),
2277 glam::Vec3::new(0.0, -1.0, 0.0),
2278 1,
2279 &item,
2280 &volume,
2281 );
2282 assert!(hit.is_some(), "expected a hit");
2283 let hit = hit.unwrap();
2284 let flat = hit.sub_object.unwrap().index();
2285 assert_eq!(flat, 1, "expected iy=1 (flat=1), got flat={flat}");
2286 assert_eq!(hit.scalar_value, Some(0.8));
2287 }
2288
2289 #[test]
2290 fn test_pick_volume_nan_skip() {
2291 let item = make_volume_item([0.0; 3], [1.0, 2.0, 1.0], 0.0, 1.0);
2294 let mut volume = make_volume_data([1, 2, 1], 0.0);
2295 volume.data[1] = f32::NAN;
2296 volume.data[0] = 0.5;
2297
2298 let hit = super::pick_volume_cpu(
2299 glam::Vec3::new(0.5, 10.0, 0.5),
2300 glam::Vec3::new(0.0, -1.0, 0.0),
2301 1,
2302 &item,
2303 &volume,
2304 );
2305 assert!(hit.is_some(), "expected hit after NaN skip");
2306 let hit = hit.unwrap();
2307 assert_eq!(hit.sub_object.unwrap().index(), 0, "expected iy=0 (flat=0)");
2308 assert_eq!(hit.scalar_value, Some(0.5));
2309 }
2310
2311 #[test]
2312 fn test_pick_volume_dda_no_skip() {
2313 let item = make_volume_item([0.0; 3], [10.0, 1.0, 1.0], 0.5, 1.0);
2317 let mut volume = make_volume_data([10, 1, 1], 0.0);
2318 volume.data[9] = 0.8;
2319
2320 let dir = glam::Vec3::new(1.0, 0.0, 0.001).normalize();
2321 let hit = super::pick_volume_cpu(glam::Vec3::new(-1.0, 0.5, 0.5), dir, 1, &item, &volume);
2322 assert!(
2323 hit.is_some(),
2324 "DDA must reach the last voxel without skipping"
2325 );
2326 let flat = hit.unwrap().sub_object.unwrap().index();
2327 assert_eq!(flat, 9, "expected ix=9 (flat=9), got flat={flat}");
2328 }
2329
2330 #[test]
2331 fn test_voxel_world_aabb_identity() {
2332 let item = make_volume_item([0.0; 3], [4.0, 4.0, 4.0], 0.0, 1.0);
2334 let volume = make_volume_data([4, 4, 4], 0.0);
2335
2336 let (lo, hi) = super::voxel_world_aabb(0, &volume, &item);
2338 assert!((lo - glam::Vec3::ZERO).length() < 1e-5, "lo={lo:?}");
2339 assert!((hi - glam::Vec3::ONE).length() < 1e-5, "hi={hi:?}");
2340
2341 let (lo, hi) = super::voxel_world_aabb(1, &volume, &item);
2343 assert!((lo.x - 1.0).abs() < 1e-5 && (hi.x - 2.0).abs() < 1e-5);
2344
2345 let (lo, hi) = super::voxel_world_aabb(57, &volume, &item);
2347 assert!(
2348 (lo - glam::Vec3::new(1.0, 2.0, 3.0)).length() < 1e-5,
2349 "lo={lo:?}"
2350 );
2351 assert!(
2352 (hi - glam::Vec3::new(2.0, 3.0, 4.0)).length() < 1e-5,
2353 "hi={hi:?}"
2354 );
2355 }
2356
2357 #[test]
2358 fn test_voxel_world_aabb_round_trip() {
2359 let item = make_volume_item([0.0; 3], [3.0, 3.0, 3.0], 0.5, 1.0);
2362 let volume = make_volume_data([3, 3, 3], 0.8);
2363
2364 let hit = super::pick_volume_cpu(
2365 glam::Vec3::new(1.5, 10.0, 1.5),
2366 glam::Vec3::new(0.0, -1.0, 0.0),
2367 1,
2368 &item,
2369 &volume,
2370 )
2371 .expect("expected a hit for round-trip test");
2372
2373 let flat = hit.sub_object.unwrap().index();
2374 let (lo, hi) = super::voxel_world_aabb(flat, &volume, &item);
2375
2376 let tol = 1e-3;
2377 assert!(
2378 hit.world_pos.x >= lo.x - tol && hit.world_pos.x <= hi.x + tol,
2379 "world_pos.x={} outside [{}, {}]",
2380 hit.world_pos.x,
2381 lo.x,
2382 hi.x
2383 );
2384 assert!(
2385 hit.world_pos.y >= lo.y - tol && hit.world_pos.y <= hi.y + tol,
2386 "world_pos.y={} outside [{}, {}]",
2387 hit.world_pos.y,
2388 lo.y,
2389 hi.y
2390 );
2391 assert!(
2392 hit.world_pos.z >= lo.z - tol && hit.world_pos.z <= hi.z + tol,
2393 "world_pos.z={} outside [{}, {}]",
2394 hit.world_pos.z,
2395 lo.z,
2396 hi.z
2397 );
2398 }
2399}