1use crate::geometry::marching_cubes::VolumeData;
6use crate::interaction::sub_object::SubObjectRef;
7use crate::resources::{AttributeData, AttributeKind, AttributeRef};
8use crate::scene::traits::ViewportObject;
9use parry3d::math::{Pose, Vector};
10use parry3d::query::{Ray, RayCast};
11
12#[derive(Clone, Copy, Debug)]
21#[non_exhaustive]
22pub struct PickHit {
23 pub id: u64,
25 pub sub_object: Option<SubObjectRef>,
30 pub world_pos: glam::Vec3,
32 pub normal: glam::Vec3,
34 #[deprecated(since = "0.5.0", note = "use `sub_object` instead")]
39 pub triangle_index: u32,
40 #[deprecated(since = "0.5.0", note = "use `sub_object` instead")]
45 pub point_index: Option<u32>,
46 pub scalar_value: Option<f32>,
53}
54
55impl PickHit {
56 #[allow(deprecated)]
59 pub fn object_hit(id: u64, world_pos: glam::Vec3, normal: glam::Vec3) -> Self {
60 Self {
61 id,
62 sub_object: None,
63 world_pos,
64 normal,
65 triangle_index: u32::MAX,
66 point_index: None,
67 scalar_value: None,
68 }
69 }
70}
71
72#[derive(Clone, Copy, Debug)]
84#[non_exhaustive]
85pub struct GpuPickHit {
86 pub object_id: crate::renderer::PickId,
93 pub depth: f32,
102}
103
104pub fn screen_to_ray(
117 screen_pos: glam::Vec2,
118 viewport_size: glam::Vec2,
119 view_proj_inv: glam::Mat4,
120) -> (glam::Vec3, glam::Vec3) {
121 let ndc_x = (screen_pos.x / viewport_size.x) * 2.0 - 1.0;
122 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));
124 let far = view_proj_inv.project_point3(glam::Vec3::new(ndc_x, ndc_y, 1.0));
125 let dir = (far - near).normalize();
126 (near, dir)
127}
128
129pub fn pick_scene_cpu(
138 ray_origin: glam::Vec3,
139 ray_dir: glam::Vec3,
140 objects: &[&dyn ViewportObject],
141 mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
142) -> Option<PickHit> {
143 let ray = Ray::new(
145 Vector::new(ray_origin.x, ray_origin.y, ray_origin.z),
146 Vector::new(ray_dir.x, ray_dir.y, ray_dir.z),
147 );
148
149 let mut best_hit: Option<(u64, f32, PickHit)> = None;
150
151 for obj in objects {
152 if !obj.is_visible() {
153 continue;
154 }
155 let Some(mesh_id) = obj.mesh_id() else {
156 continue;
157 };
158
159 if let Some((positions, indices)) = mesh_lookup.get(&mesh_id) {
160 let s = obj.scale();
165 let verts: Vec<Vector> = positions
166 .iter()
167 .map(|p: &[f32; 3]| Vector::new(p[0] * s.x, p[1] * s.y, p[2] * s.z))
168 .collect();
169
170 let tri_indices: Vec<[u32; 3]> = indices
171 .chunks(3)
172 .filter(|c: &&[u32]| c.len() == 3)
173 .map(|c: &[u32]| [c[0], c[1], c[2]])
174 .collect();
175
176 if tri_indices.is_empty() {
177 continue;
178 }
179
180 match parry3d::shape::TriMesh::new(verts, tri_indices) {
181 Ok(trimesh) => {
182 let pose = Pose::from_parts(obj.position(), obj.rotation());
186 if let Some(intersection) =
187 trimesh.cast_ray_and_get_normal(&pose, &ray, f32::MAX, true)
188 {
189 let toi = intersection.time_of_impact;
190 if best_hit.is_none() || toi < best_hit.as_ref().unwrap().1 {
191 let sub_object = SubObjectRef::from_feature_id(intersection.feature);
192 let world_pos = ray_origin + ray_dir * toi;
193 let normal = intersection.normal;
195 let triangle_index = if let Some(SubObjectRef::Face(i)) = sub_object {
196 i
197 } else {
198 u32::MAX
199 };
200 #[allow(deprecated)]
201 let hit = PickHit {
202 id: obj.id(),
203 sub_object,
204 triangle_index,
205 world_pos,
206 normal,
207 point_index: None,
208 scalar_value: None,
209 };
210 best_hit = Some((obj.id(), toi, hit));
211 }
212 }
213 }
214 Err(e) => {
215 tracing::warn!(object_id = obj.id(), error = %e, "TriMesh construction failed for picking");
216 }
217 }
218 }
219 }
220
221 best_hit.map(|(_, _, hit)| hit)
222}
223
224pub fn pick_scene_nodes_cpu(
229 ray_origin: glam::Vec3,
230 ray_dir: glam::Vec3,
231 scene: &crate::scene::scene::Scene,
232 mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
233) -> Option<PickHit> {
234 let nodes: Vec<&dyn ViewportObject> = scene.nodes().map(|n| n as &dyn ViewportObject).collect();
235 pick_scene_cpu(ray_origin, ray_dir, &nodes, mesh_lookup)
236}
237
238pub struct ProbeBinding<'a> {
247 pub id: u64,
249 pub attribute_ref: &'a AttributeRef,
251 pub attribute_data: &'a AttributeData,
253 pub positions: &'a [[f32; 3]],
255 pub indices: &'a [u32],
257}
258
259fn barycentric(p: glam::Vec3, a: glam::Vec3, b: glam::Vec3, c: glam::Vec3) -> (f32, f32, f32) {
264 let v0 = b - a;
265 let v1 = c - a;
266 let v2 = p - a;
267 let d00 = v0.dot(v0);
268 let d01 = v0.dot(v1);
269 let d11 = v1.dot(v1);
270 let d20 = v2.dot(v0);
271 let d21 = v2.dot(v1);
272 let denom = d00 * d11 - d01 * d01;
273 if denom.abs() < 1e-12 {
274 return (1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0);
276 }
277 let inv = 1.0 / denom;
278 let v = (d11 * d20 - d01 * d21) * inv;
279 let w = (d00 * d21 - d01 * d20) * inv;
280 let u = 1.0 - v - w;
281 (u, v, w)
282}
283
284fn probe_scalar(hit: &mut PickHit, binding: &ProbeBinding<'_>) {
287 let tri_idx_raw = match hit.sub_object {
288 Some(SubObjectRef::Face(i)) => i,
289 _ => return,
290 };
291
292 let num_triangles = binding.indices.len() / 3;
293 let tri_idx = if (tri_idx_raw as usize) >= num_triangles && num_triangles > 0 {
296 tri_idx_raw as usize - num_triangles
297 } else {
298 tri_idx_raw as usize
299 };
300
301 match binding.attribute_ref.kind {
302 AttributeKind::Cell => {
303 if let AttributeData::Cell(data) = binding.attribute_data {
305 if let Some(&val) = data.get(tri_idx) {
306 hit.scalar_value = Some(val);
307 }
308 }
309 }
310 AttributeKind::Face => {
311 if let AttributeData::Face(data) = binding.attribute_data {
313 if let Some(&val) = data.get(tri_idx) {
314 hit.scalar_value = Some(val);
315 }
316 }
317 }
318 AttributeKind::FaceColor => {
319 }
321 AttributeKind::Vertex => {
322 if let AttributeData::Vertex(data) = binding.attribute_data {
324 let base = tri_idx * 3;
325 if base + 2 >= binding.indices.len() {
326 return;
327 }
328 let i0 = binding.indices[base] as usize;
329 let i1 = binding.indices[base + 1] as usize;
330 let i2 = binding.indices[base + 2] as usize;
331
332 if i0 >= data.len() || i1 >= data.len() || i2 >= data.len() {
333 return;
334 }
335 if i0 >= binding.positions.len()
336 || i1 >= binding.positions.len()
337 || i2 >= binding.positions.len()
338 {
339 return;
340 }
341
342 let a = glam::Vec3::from(binding.positions[i0]);
343 let b = glam::Vec3::from(binding.positions[i1]);
344 let c = glam::Vec3::from(binding.positions[i2]);
345 let (u, v, w) = barycentric(hit.world_pos, a, b, c);
346 hit.scalar_value = Some(data[i0] * u + data[i1] * v + data[i2] * w);
347 }
348 }
349 AttributeKind::Edge => {
350 if let AttributeData::Edge(data) = binding.attribute_data {
353 let base = tri_idx * 3;
354 if base + 2 >= binding.indices.len() || data.is_empty() {
355 return;
356 }
357 let i0 = binding.indices[base] as usize;
358 let i1 = binding.indices[base + 1] as usize;
359 let i2 = binding.indices[base + 2] as usize;
360 if i0 < data.len() || i1 < data.len() || i2 < data.len() {
361 if i0 < data.len()
363 && i1 < data.len()
364 && i2 < data.len()
365 && i0 < binding.positions.len()
366 && i1 < binding.positions.len()
367 && i2 < binding.positions.len()
368 {
369 let a = glam::Vec3::from(binding.positions[i0]);
370 let b = glam::Vec3::from(binding.positions[i1]);
371 let c = glam::Vec3::from(binding.positions[i2]);
372 let (u, v, w) = barycentric(hit.world_pos, a, b, c);
373 hit.scalar_value = Some(data[i0] * u + data[i1] * v + data[i2] * w);
374 }
375 }
376 }
377 }
378 AttributeKind::Halfedge | AttributeKind::Corner => {
379 let extract = |data: &[f32]| -> Option<f32> {
382 let base = tri_idx * 3;
383 if base + 2 >= data.len() {
384 return None;
385 }
386 Some(data[base])
388 };
389 match binding.attribute_data {
390 AttributeData::Halfedge(data) | AttributeData::Corner(data) => {
391 hit.scalar_value = extract(data);
392 }
393 _ => {}
394 }
395 }
396 }
397}
398
399pub fn pick_scene_with_probe_cpu(
406 ray_origin: glam::Vec3,
407 ray_dir: glam::Vec3,
408 objects: &[&dyn ViewportObject],
409 mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
410 probe_bindings: &[ProbeBinding<'_>],
411) -> Option<PickHit> {
412 let mut hit = pick_scene_cpu(ray_origin, ray_dir, objects, mesh_lookup)?;
413 if let Some(binding) = probe_bindings.iter().find(|b| b.id == hit.id) {
414 probe_scalar(&mut hit, binding);
415 }
416 Some(hit)
417}
418
419pub fn pick_scene_nodes_with_probe_cpu(
423 ray_origin: glam::Vec3,
424 ray_dir: glam::Vec3,
425 scene: &crate::scene::scene::Scene,
426 mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
427 probe_bindings: &[ProbeBinding<'_>],
428) -> Option<PickHit> {
429 let mut hit = pick_scene_nodes_cpu(ray_origin, ray_dir, scene, mesh_lookup)?;
430 if let Some(binding) = probe_bindings.iter().find(|b| b.id == hit.id) {
431 probe_scalar(&mut hit, binding);
432 }
433 Some(hit)
434}
435
436pub fn pick_scene_accelerated_with_probe_cpu(
441 ray_origin: glam::Vec3,
442 ray_dir: glam::Vec3,
443 accelerator: &mut crate::geometry::bvh::PickAccelerator,
444 mesh_lookup: &std::collections::HashMap<u64, (Vec<[f32; 3]>, Vec<u32>)>,
445 probe_bindings: &[ProbeBinding<'_>],
446) -> Option<PickHit> {
447 let mut hit = accelerator.pick(ray_origin, ray_dir, mesh_lookup)?;
448 if let Some(binding) = probe_bindings.iter().find(|b| b.id == hit.id) {
449 probe_scalar(&mut hit, binding);
450 }
451 Some(hit)
452}
453
454#[derive(Clone, Debug, Default)]
465pub struct RectPickResult {
466 pub hits: std::collections::HashMap<u64, Vec<SubObjectRef>>,
473}
474
475impl RectPickResult {
476 pub fn is_empty(&self) -> bool {
478 self.hits.is_empty()
479 }
480
481 pub fn total_count(&self) -> usize {
483 self.hits.values().map(|v| v.len()).sum()
484 }
485}
486
487pub fn pick_rect(
505 rect_min: glam::Vec2,
506 rect_max: glam::Vec2,
507 scene_items: &[crate::renderer::SceneRenderItem],
508 mesh_lookup: &std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)>,
509 point_clouds: &[crate::renderer::PointCloudItem],
510 view_proj: glam::Mat4,
511 viewport_size: glam::Vec2,
512) -> RectPickResult {
513 let ndc_min = glam::Vec2::new(
516 rect_min.x / viewport_size.x * 2.0 - 1.0,
517 1.0 - rect_max.y / viewport_size.y * 2.0, );
519 let ndc_max = glam::Vec2::new(
520 rect_max.x / viewport_size.x * 2.0 - 1.0,
521 1.0 - rect_min.y / viewport_size.y * 2.0, );
523
524 let mut result = RectPickResult::default();
525
526 for item in scene_items {
528 if !item.visible {
529 continue;
530 }
531 let Some((positions, indices)) = mesh_lookup.get(&item.mesh_id.index()) else {
532 continue;
533 };
534
535 let model = glam::Mat4::from_cols_array_2d(&item.model);
536 let mvp = view_proj * model;
537
538 let mut tri_hits: Vec<SubObjectRef> = Vec::new();
539
540 for (tri_idx, chunk) in indices.chunks(3).enumerate() {
541 if chunk.len() < 3 {
542 continue;
543 }
544 let i0 = chunk[0] as usize;
545 let i1 = chunk[1] as usize;
546 let i2 = chunk[2] as usize;
547
548 if i0 >= positions.len() || i1 >= positions.len() || i2 >= positions.len() {
549 continue;
550 }
551
552 let p0 = glam::Vec3::from(positions[i0]);
553 let p1 = glam::Vec3::from(positions[i1]);
554 let p2 = glam::Vec3::from(positions[i2]);
555 let centroid = (p0 + p1 + p2) / 3.0;
556
557 let clip = mvp * centroid.extend(1.0);
558 if clip.w <= 0.0 {
559 continue;
561 }
562 let ndc = glam::Vec2::new(clip.x / clip.w, clip.y / clip.w);
563
564 if ndc.x >= ndc_min.x && ndc.x <= ndc_max.x && ndc.y >= ndc_min.y && ndc.y <= ndc_max.y
565 {
566 tri_hits.push(SubObjectRef::Face(tri_idx as u32));
567 }
568 }
569
570 if !tri_hits.is_empty() {
571 result.hits.insert(item.pick_id.0, tri_hits);
572 }
573 }
574
575 for pc in point_clouds {
577 if pc.id == 0 {
578 continue;
580 }
581
582 let model = glam::Mat4::from_cols_array_2d(&pc.model);
583 let mvp = view_proj * model;
584
585 let mut pt_hits: Vec<SubObjectRef> = Vec::new();
586
587 for (pt_idx, pos) in pc.positions.iter().enumerate() {
588 let p = glam::Vec3::from(*pos);
589 let clip = mvp * p.extend(1.0);
590 if clip.w <= 0.0 {
591 continue;
592 }
593 let ndc = glam::Vec2::new(clip.x / clip.w, clip.y / clip.w);
594
595 if ndc.x >= ndc_min.x && ndc.x <= ndc_max.x && ndc.y >= ndc_min.y && ndc.y <= ndc_max.y
596 {
597 pt_hits.push(SubObjectRef::Point(pt_idx as u32));
598 }
599 }
600
601 if !pt_hits.is_empty() {
602 result.hits.insert(pc.id, pt_hits);
603 }
604 }
605
606 result
607}
608
609pub fn box_select(
618 rect_min: glam::Vec2,
619 rect_max: glam::Vec2,
620 objects: &[&dyn ViewportObject],
621 view_proj: glam::Mat4,
622 viewport_size: glam::Vec2,
623) -> Vec<u64> {
624 let mut hits = Vec::new();
625 for obj in objects {
626 if !obj.is_visible() {
627 continue;
628 }
629 let pos = obj.position();
630 let clip = view_proj * pos.extend(1.0);
631 if clip.w <= 0.0 {
633 continue;
634 }
635 let ndc = glam::Vec3::new(clip.x / clip.w, clip.y / clip.w, clip.z / clip.w);
636 let screen = glam::Vec2::new(
637 (ndc.x + 1.0) * 0.5 * viewport_size.x,
638 (1.0 - ndc.y) * 0.5 * viewport_size.y,
639 );
640 if screen.x >= rect_min.x
641 && screen.x <= rect_max.x
642 && screen.y >= rect_min.y
643 && screen.y <= rect_max.y
644 {
645 hits.push(obj.id());
646 }
647 }
648 hits
649}
650
651fn ray_aabb_volume(
662 origin: glam::Vec3,
663 dir: glam::Vec3,
664 bbox_min: glam::Vec3,
665 bbox_max: glam::Vec3,
666) -> Option<(f32, f32, usize, f32)> {
667 let mut t_min = f32::NEG_INFINITY;
668 let mut t_max = f32::INFINITY;
669 let mut entry_axis = 0usize;
670 let mut entry_sign = -1.0f32;
671
672 let dirs = [dir.x, dir.y, dir.z];
673 let origins = [origin.x, origin.y, origin.z];
674 let mins = [bbox_min.x, bbox_min.y, bbox_min.z];
675 let maxs = [bbox_max.x, bbox_max.y, bbox_max.z];
676
677 for i in 0..3 {
678 let d = dirs[i];
679 let o = origins[i];
680 if d.abs() < 1e-12 {
681 if o < mins[i] || o > maxs[i] {
683 return None;
684 }
685 } else {
686 let t1 = (mins[i] - o) / d;
687 let t2 = (maxs[i] - o) / d;
688 let (t_near, t_far) = if t1 <= t2 { (t1, t2) } else { (t2, t1) };
689 if t_near > t_min {
690 t_min = t_near;
691 entry_axis = i;
692 entry_sign = if d > 0.0 { -1.0 } else { 1.0 };
695 }
696 if t_far < t_max {
697 t_max = t_far;
698 }
699 }
700 }
701
702 if t_min > t_max || t_max < 0.0 {
703 return None;
704 }
705 Some((t_min, t_max, entry_axis, entry_sign))
706}
707
708pub fn pick_volume_cpu(
733 ray_origin: glam::Vec3,
734 ray_dir: glam::Vec3,
735 id: u64,
736 item: &crate::renderer::VolumeItem,
737 volume: &VolumeData,
738) -> Option<PickHit> {
739 let [nx, ny, nz] = volume.dims;
740 if nx == 0 || ny == 0 || nz == 0 || volume.data.is_empty() {
741 return None;
742 }
743
744 let model = glam::Mat4::from_cols_array_2d(&item.model);
746 let inv_model = model.inverse();
747 let local_origin = inv_model.transform_point3(ray_origin);
748 let local_dir = inv_model.transform_vector3(ray_dir);
749
750 let bbox_min = glam::Vec3::from(item.bbox_min);
751 let bbox_max = glam::Vec3::from(item.bbox_max);
752
753 let (t_entry, t_exit, entry_axis, entry_sign) =
754 ray_aabb_volume(local_origin, local_dir, bbox_min, bbox_max)?;
755
756 let t_start = t_entry.max(0.0);
758 if t_start >= t_exit {
759 return None;
760 }
761
762 let extent = bbox_max - bbox_min;
764 let cell = extent / glam::Vec3::new(nx as f32, ny as f32, nz as f32);
765
766 let p_entry = local_origin + t_start * local_dir;
768
769 let eps = 1e-4_f32;
772 let frac = ((p_entry - bbox_min) / extent).clamp(
773 glam::Vec3::splat(eps),
774 glam::Vec3::splat(1.0 - eps),
775 );
776 let mut ix = (frac.x * nx as f32).floor() as i32;
777 let mut iy = (frac.y * ny as f32).floor() as i32;
778 let mut iz = (frac.z * nz as f32).floor() as i32;
779 ix = ix.clamp(0, nx as i32 - 1);
780 iy = iy.clamp(0, ny as i32 - 1);
781 iz = iz.clamp(0, nz as i32 - 1);
782
783 let step_x: i32 = if local_dir.x >= 0.0 { 1 } else { -1 };
785 let step_y: i32 = if local_dir.y >= 0.0 { 1 } else { -1 };
786 let step_z: i32 = if local_dir.z >= 0.0 { 1 } else { -1 };
787
788 let td_x = if local_dir.x.abs() > 1e-12 { cell.x / local_dir.x.abs() } else { f32::INFINITY };
790 let td_y = if local_dir.y.abs() > 1e-12 { cell.y / local_dir.y.abs() } else { f32::INFINITY };
791 let td_z = if local_dir.z.abs() > 1e-12 { cell.z / local_dir.z.abs() } else { f32::INFINITY };
792
793 let next_bx = bbox_min.x + (if step_x > 0 { ix + 1 } else { ix }) as f32 * cell.x;
795 let next_by = bbox_min.y + (if step_y > 0 { iy + 1 } else { iy }) as f32 * cell.y;
796 let next_bz = bbox_min.z + (if step_z > 0 { iz + 1 } else { iz }) as f32 * cell.z;
797
798 let mut tmax_x = if local_dir.x.abs() > 1e-12 {
799 t_start + (next_bx - p_entry.x) / local_dir.x
800 } else {
801 f32::INFINITY
802 };
803 let mut tmax_y = if local_dir.y.abs() > 1e-12 {
804 t_start + (next_by - p_entry.y) / local_dir.y
805 } else {
806 f32::INFINITY
807 };
808 let mut tmax_z = if local_dir.z.abs() > 1e-12 {
809 t_start + (next_bz - p_entry.z) / local_dir.z
810 } else {
811 f32::INFINITY
812 };
813
814 let mut entry_normal_local = glam::Vec3::ZERO;
816 match entry_axis {
817 0 => entry_normal_local.x = entry_sign,
818 1 => entry_normal_local.y = entry_sign,
819 _ => entry_normal_local.z = entry_sign,
820 }
821
822 let mut t_voxel_entry = t_start;
824
825 loop {
826 if ix < 0 || ix >= nx as i32 || iy < 0 || iy >= ny as i32 || iz < 0 || iz >= nz as i32 {
828 break;
829 }
830
831 let flat = ix as u32 + iy as u32 * nx + iz as u32 * nx * ny;
832 let scalar = volume.data[flat as usize];
833
834 if !scalar.is_nan() && scalar >= item.threshold_min && scalar <= item.threshold_max {
836 let local_hit = local_origin + t_voxel_entry * local_dir;
837 let world_pos = model.transform_point3(local_hit);
838 let world_normal = inv_model
840 .transpose()
841 .transform_vector3(entry_normal_local)
842 .normalize();
843
844 #[allow(deprecated)]
845 return Some(PickHit {
846 id,
847 sub_object: Some(SubObjectRef::Voxel(flat)),
848 world_pos,
849 normal: world_normal,
850 triangle_index: u32::MAX,
851 point_index: None,
852 scalar_value: Some(scalar),
853 });
854 }
855
856 if tmax_x <= tmax_y && tmax_x <= tmax_z {
858 if tmax_x > t_exit {
859 break;
860 }
861 t_voxel_entry = tmax_x;
862 tmax_x += td_x;
863 ix += step_x;
864 entry_normal_local = glam::Vec3::new(-(step_x as f32), 0.0, 0.0);
865 } else if tmax_y <= tmax_z {
866 if tmax_y > t_exit {
867 break;
868 }
869 t_voxel_entry = tmax_y;
870 tmax_y += td_y;
871 iy += step_y;
872 entry_normal_local = glam::Vec3::new(0.0, -(step_y as f32), 0.0);
873 } else {
874 if tmax_z > t_exit {
875 break;
876 }
877 t_voxel_entry = tmax_z;
878 tmax_z += td_z;
879 iz += step_z;
880 entry_normal_local = glam::Vec3::new(0.0, 0.0, -(step_z as f32));
881 }
882 }
883
884 None
885}
886
887pub fn voxel_world_aabb(
901 flat_index: u32,
902 volume: &VolumeData,
903 item: &crate::renderer::VolumeItem,
904) -> (glam::Vec3, glam::Vec3) {
905 let [nx, ny, nz] = volume.dims;
906 let ix = flat_index % nx;
907 let iy = (flat_index / nx) % ny;
908 let iz = flat_index / (nx * ny);
909 assert!(
910 ix < nx && iy < ny && iz < nz,
911 "flat_index {} out of bounds for dims {:?}",
912 flat_index,
913 volume.dims
914 );
915
916 let bbox_min = glam::Vec3::from(item.bbox_min);
917 let bbox_max = glam::Vec3::from(item.bbox_max);
918 let cell = (bbox_max - bbox_min) / glam::Vec3::new(nx as f32, ny as f32, nz as f32);
919
920 let local_lo = bbox_min
921 + glam::Vec3::new(ix as f32 * cell.x, iy as f32 * cell.y, iz as f32 * cell.z);
922 let local_hi = local_lo + cell;
923
924 let model = glam::Mat4::from_cols_array_2d(&item.model);
925 let corners = [
926 glam::Vec3::new(local_lo.x, local_lo.y, local_lo.z),
927 glam::Vec3::new(local_hi.x, local_lo.y, local_lo.z),
928 glam::Vec3::new(local_lo.x, local_hi.y, local_lo.z),
929 glam::Vec3::new(local_hi.x, local_hi.y, local_lo.z),
930 glam::Vec3::new(local_lo.x, local_lo.y, local_hi.z),
931 glam::Vec3::new(local_hi.x, local_lo.y, local_hi.z),
932 glam::Vec3::new(local_lo.x, local_hi.y, local_hi.z),
933 glam::Vec3::new(local_hi.x, local_hi.y, local_hi.z),
934 ];
935
936 let world_min = corners
937 .iter()
938 .map(|&c| model.transform_point3(c))
939 .fold(glam::Vec3::splat(f32::INFINITY), |acc, c| acc.min(c));
940 let world_max = corners
941 .iter()
942 .map(|&c| model.transform_point3(c))
943 .fold(glam::Vec3::splat(f32::NEG_INFINITY), |acc, c| acc.max(c));
944
945 (world_min, world_max)
946}
947
948#[cfg(test)]
949mod tests {
950 use super::*;
951 use crate::scene::traits::ViewportObject;
952 use std::collections::HashMap;
953
954 struct TestObject {
955 id: u64,
956 mesh_id: u64,
957 position: glam::Vec3,
958 visible: bool,
959 }
960
961 impl ViewportObject for TestObject {
962 fn id(&self) -> u64 {
963 self.id
964 }
965 fn mesh_id(&self) -> Option<u64> {
966 Some(self.mesh_id)
967 }
968 fn model_matrix(&self) -> glam::Mat4 {
969 glam::Mat4::from_translation(self.position)
970 }
971 fn position(&self) -> glam::Vec3 {
972 self.position
973 }
974 fn rotation(&self) -> glam::Quat {
975 glam::Quat::IDENTITY
976 }
977 fn is_visible(&self) -> bool {
978 self.visible
979 }
980 fn color(&self) -> glam::Vec3 {
981 glam::Vec3::ONE
982 }
983 }
984
985 fn unit_cube_mesh() -> (Vec<[f32; 3]>, Vec<u32>) {
987 let positions = vec![
988 [-0.5, -0.5, -0.5],
989 [0.5, -0.5, -0.5],
990 [0.5, 0.5, -0.5],
991 [-0.5, 0.5, -0.5],
992 [-0.5, -0.5, 0.5],
993 [0.5, -0.5, 0.5],
994 [0.5, 0.5, 0.5],
995 [-0.5, 0.5, 0.5],
996 ];
997 let indices = vec![
998 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, ];
1005 (positions, indices)
1006 }
1007
1008 #[test]
1009 fn test_screen_to_ray_center() {
1010 let vp_inv = glam::Mat4::IDENTITY;
1012 let (origin, dir) = screen_to_ray(
1013 glam::Vec2::new(400.0, 300.0),
1014 glam::Vec2::new(800.0, 600.0),
1015 vp_inv,
1016 );
1017 assert!(origin.x.abs() < 1e-3, "origin.x={}", origin.x);
1019 assert!(origin.y.abs() < 1e-3, "origin.y={}", origin.y);
1020 assert!(dir.z.abs() > 0.9, "dir should be along Z, got {dir:?}");
1021 }
1022
1023 #[test]
1024 fn test_pick_scene_hit() {
1025 let (positions, indices) = unit_cube_mesh();
1026 let mut mesh_lookup = HashMap::new();
1027 mesh_lookup.insert(1u64, (positions, indices));
1028
1029 let obj = TestObject {
1030 id: 42,
1031 mesh_id: 1,
1032 position: glam::Vec3::ZERO,
1033 visible: true,
1034 };
1035 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1036
1037 let result = pick_scene_cpu(
1039 glam::Vec3::new(0.0, 0.0, 5.0),
1040 glam::Vec3::new(0.0, 0.0, -1.0),
1041 &objects,
1042 &mesh_lookup,
1043 );
1044 assert!(result.is_some(), "expected a hit");
1045 let hit = result.unwrap();
1046 assert_eq!(hit.id, 42);
1047 assert!(
1049 (hit.world_pos.z - 0.5).abs() < 0.01,
1050 "world_pos.z={}",
1051 hit.world_pos.z
1052 );
1053 assert!(hit.normal.z > 0.9, "normal={:?}", hit.normal);
1055 }
1056
1057 #[test]
1058 fn test_pick_scene_miss() {
1059 let (positions, indices) = unit_cube_mesh();
1060 let mut mesh_lookup = HashMap::new();
1061 mesh_lookup.insert(1u64, (positions, indices));
1062
1063 let obj = TestObject {
1064 id: 42,
1065 mesh_id: 1,
1066 position: glam::Vec3::ZERO,
1067 visible: true,
1068 };
1069 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1070
1071 let result = pick_scene_cpu(
1073 glam::Vec3::new(100.0, 100.0, 5.0),
1074 glam::Vec3::new(0.0, 0.0, -1.0),
1075 &objects,
1076 &mesh_lookup,
1077 );
1078 assert!(result.is_none());
1079 }
1080
1081 #[test]
1082 fn test_pick_nearest_wins() {
1083 let (positions, indices) = unit_cube_mesh();
1084 let mut mesh_lookup = HashMap::new();
1085 mesh_lookup.insert(1u64, (positions.clone(), indices.clone()));
1086 mesh_lookup.insert(2u64, (positions, indices));
1087
1088 let near_obj = TestObject {
1089 id: 10,
1090 mesh_id: 1,
1091 position: glam::Vec3::new(0.0, 0.0, 2.0),
1092 visible: true,
1093 };
1094 let far_obj = TestObject {
1095 id: 20,
1096 mesh_id: 2,
1097 position: glam::Vec3::new(0.0, 0.0, -2.0),
1098 visible: true,
1099 };
1100 let objects: Vec<&dyn ViewportObject> = vec![&far_obj, &near_obj];
1101
1102 let result = pick_scene_cpu(
1104 glam::Vec3::new(0.0, 0.0, 10.0),
1105 glam::Vec3::new(0.0, 0.0, -1.0),
1106 &objects,
1107 &mesh_lookup,
1108 );
1109 assert!(result.is_some(), "expected a hit");
1110 assert_eq!(result.unwrap().id, 10);
1111 }
1112
1113 #[test]
1114 fn test_box_select_hits_inside_rect() {
1115 let view = glam::Mat4::look_at_rh(
1117 glam::Vec3::new(0.0, 0.0, 5.0),
1118 glam::Vec3::ZERO,
1119 glam::Vec3::Y,
1120 );
1121 let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
1122 let vp = proj * view;
1123 let viewport_size = glam::Vec2::new(800.0, 600.0);
1124
1125 let obj = TestObject {
1126 id: 42,
1127 mesh_id: 1,
1128 position: glam::Vec3::ZERO,
1129 visible: true,
1130 };
1131 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1132
1133 let result = box_select(
1135 glam::Vec2::new(300.0, 200.0),
1136 glam::Vec2::new(500.0, 400.0),
1137 &objects,
1138 vp,
1139 viewport_size,
1140 );
1141 assert_eq!(result, vec![42]);
1142 }
1143
1144 #[test]
1145 fn test_box_select_skips_hidden() {
1146 let view = glam::Mat4::look_at_rh(
1147 glam::Vec3::new(0.0, 0.0, 5.0),
1148 glam::Vec3::ZERO,
1149 glam::Vec3::Y,
1150 );
1151 let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
1152 let vp = proj * view;
1153 let viewport_size = glam::Vec2::new(800.0, 600.0);
1154
1155 let obj = TestObject {
1156 id: 42,
1157 mesh_id: 1,
1158 position: glam::Vec3::ZERO,
1159 visible: false,
1160 };
1161 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1162
1163 let result = box_select(
1164 glam::Vec2::new(0.0, 0.0),
1165 glam::Vec2::new(800.0, 600.0),
1166 &objects,
1167 vp,
1168 viewport_size,
1169 );
1170 assert!(result.is_empty());
1171 }
1172
1173 #[test]
1174 fn test_pick_scene_nodes_hit() {
1175 let (positions, indices) = unit_cube_mesh();
1176 let mut mesh_lookup = HashMap::new();
1177 mesh_lookup.insert(0u64, (positions, indices));
1178
1179 let mut scene = crate::scene::scene::Scene::new();
1180 scene.add(
1181 Some(crate::resources::mesh_store::MeshId(0)),
1182 glam::Mat4::IDENTITY,
1183 crate::scene::material::Material::default(),
1184 );
1185 scene.update_transforms();
1186
1187 let result = pick_scene_nodes_cpu(
1188 glam::Vec3::new(0.0, 0.0, 5.0),
1189 glam::Vec3::new(0.0, 0.0, -1.0),
1190 &scene,
1191 &mesh_lookup,
1192 );
1193 assert!(result.is_some());
1194 }
1195
1196 #[test]
1197 fn test_pick_scene_nodes_miss() {
1198 let (positions, indices) = unit_cube_mesh();
1199 let mut mesh_lookup = HashMap::new();
1200 mesh_lookup.insert(0u64, (positions, indices));
1201
1202 let mut scene = crate::scene::scene::Scene::new();
1203 scene.add(
1204 Some(crate::resources::mesh_store::MeshId(0)),
1205 glam::Mat4::IDENTITY,
1206 crate::scene::material::Material::default(),
1207 );
1208 scene.update_transforms();
1209
1210 let result = pick_scene_nodes_cpu(
1211 glam::Vec3::new(100.0, 100.0, 5.0),
1212 glam::Vec3::new(0.0, 0.0, -1.0),
1213 &scene,
1214 &mesh_lookup,
1215 );
1216 assert!(result.is_none());
1217 }
1218
1219 #[test]
1220 fn test_probe_vertex_attribute() {
1221 let (positions, indices) = unit_cube_mesh();
1222 let mut mesh_lookup = HashMap::new();
1223 mesh_lookup.insert(1u64, (positions.clone(), indices.clone()));
1224
1225 let vertex_scalars: Vec<f32> = (0..positions.len()).map(|i| i as f32).collect();
1227
1228 let obj = TestObject {
1229 id: 42,
1230 mesh_id: 1,
1231 position: glam::Vec3::ZERO,
1232 visible: true,
1233 };
1234 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1235
1236 let attr_ref = AttributeRef {
1237 name: "test".to_string(),
1238 kind: AttributeKind::Vertex,
1239 };
1240 let attr_data = AttributeData::Vertex(vertex_scalars);
1241 let bindings = vec![ProbeBinding {
1242 id: 42,
1243 attribute_ref: &attr_ref,
1244 attribute_data: &attr_data,
1245 positions: &positions,
1246 indices: &indices,
1247 }];
1248
1249 let result = pick_scene_with_probe_cpu(
1250 glam::Vec3::new(0.0, 0.0, 5.0),
1251 glam::Vec3::new(0.0, 0.0, -1.0),
1252 &objects,
1253 &mesh_lookup,
1254 &bindings,
1255 );
1256 assert!(result.is_some(), "expected a hit");
1257 let hit = result.unwrap();
1258 assert_eq!(hit.id, 42);
1259 assert!(
1261 hit.scalar_value.is_some(),
1262 "expected scalar_value to be set"
1263 );
1264 }
1265
1266 #[test]
1267 fn test_probe_cell_attribute() {
1268 let (positions, indices) = unit_cube_mesh();
1269 let mut mesh_lookup = HashMap::new();
1270 mesh_lookup.insert(1u64, (positions.clone(), indices.clone()));
1271
1272 let num_triangles = indices.len() / 3;
1274 let cell_scalars: Vec<f32> = (0..num_triangles).map(|i| (i as f32) * 10.0).collect();
1275
1276 let obj = TestObject {
1277 id: 42,
1278 mesh_id: 1,
1279 position: glam::Vec3::ZERO,
1280 visible: true,
1281 };
1282 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1283
1284 let attr_ref = AttributeRef {
1285 name: "pressure".to_string(),
1286 kind: AttributeKind::Cell,
1287 };
1288 let attr_data = AttributeData::Cell(cell_scalars.clone());
1289 let bindings = vec![ProbeBinding {
1290 id: 42,
1291 attribute_ref: &attr_ref,
1292 attribute_data: &attr_data,
1293 positions: &positions,
1294 indices: &indices,
1295 }];
1296
1297 let result = pick_scene_with_probe_cpu(
1298 glam::Vec3::new(0.0, 0.0, 5.0),
1299 glam::Vec3::new(0.0, 0.0, -1.0),
1300 &objects,
1301 &mesh_lookup,
1302 &bindings,
1303 );
1304 assert!(result.is_some());
1305 let hit = result.unwrap();
1306 assert!(hit.scalar_value.is_some());
1308 let val = hit.scalar_value.unwrap();
1309 assert!(
1310 cell_scalars.contains(&val),
1311 "scalar_value {val} not in cell_scalars"
1312 );
1313 }
1314
1315 #[test]
1316 fn test_probe_no_binding_leaves_none() {
1317 let (positions, indices) = unit_cube_mesh();
1318 let mut mesh_lookup = HashMap::new();
1319 mesh_lookup.insert(1u64, (positions, indices));
1320
1321 let obj = TestObject {
1322 id: 42,
1323 mesh_id: 1,
1324 position: glam::Vec3::ZERO,
1325 visible: true,
1326 };
1327 let objects: Vec<&dyn ViewportObject> = vec![&obj];
1328
1329 let result = pick_scene_with_probe_cpu(
1331 glam::Vec3::new(0.0, 0.0, 5.0),
1332 glam::Vec3::new(0.0, 0.0, -1.0),
1333 &objects,
1334 &mesh_lookup,
1335 &[],
1336 );
1337 assert!(result.is_some());
1338 assert!(result.unwrap().scalar_value.is_none());
1339 }
1340
1341 fn make_view_proj() -> glam::Mat4 {
1347 let view = glam::Mat4::look_at_rh(
1348 glam::Vec3::new(0.0, 0.0, 5.0),
1349 glam::Vec3::ZERO,
1350 glam::Vec3::Y,
1351 );
1352 let proj = glam::Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
1353 proj * view
1354 }
1355
1356 #[test]
1357 fn test_pick_rect_mesh_full_screen() {
1358 let (positions, indices) = unit_cube_mesh();
1360 let mut mesh_lookup: std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)> =
1361 std::collections::HashMap::new();
1362 mesh_lookup.insert(0, (positions, indices.clone()));
1363
1364 let item = crate::renderer::SceneRenderItem {
1365 mesh_id: crate::resources::mesh_store::MeshId(0),
1366 model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1367 visible: true,
1368 ..Default::default()
1369 };
1370
1371 let view_proj = make_view_proj();
1372 let viewport = glam::Vec2::new(800.0, 600.0);
1373
1374 let result = pick_rect(
1375 glam::Vec2::ZERO,
1376 viewport,
1377 &[item],
1378 &mesh_lookup,
1379 &[],
1380 view_proj,
1381 viewport,
1382 );
1383
1384 assert!(!result.is_empty(), "expected at least one triangle hit");
1386 assert!(result.total_count() > 0);
1387 }
1388
1389 #[test]
1390 fn test_pick_rect_miss() {
1391 let (positions, indices) = unit_cube_mesh();
1393 let mut mesh_lookup: std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)> =
1394 std::collections::HashMap::new();
1395 mesh_lookup.insert(0, (positions, indices));
1396
1397 let item = crate::renderer::SceneRenderItem {
1398 mesh_id: crate::resources::mesh_store::MeshId(0),
1399 model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1400 visible: true,
1401 ..Default::default()
1402 };
1403
1404 let view_proj = make_view_proj();
1405 let viewport = glam::Vec2::new(800.0, 600.0);
1406
1407 let result = pick_rect(
1408 glam::Vec2::new(700.0, 500.0), glam::Vec2::new(799.0, 599.0),
1410 &[item],
1411 &mesh_lookup,
1412 &[],
1413 view_proj,
1414 viewport,
1415 );
1416
1417 assert!(result.is_empty(), "expected no hits in off-center rect");
1418 }
1419
1420 #[test]
1421 fn test_pick_rect_point_cloud() {
1422 let view_proj = make_view_proj();
1424 let viewport = glam::Vec2::new(800.0, 600.0);
1425
1426 let pc = crate::renderer::PointCloudItem {
1427 positions: vec![[0.0, 0.0, 0.0], [0.1, 0.1, 0.0]],
1428 model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1429 id: 99,
1430 ..Default::default()
1431 };
1432
1433 let result = pick_rect(
1434 glam::Vec2::ZERO,
1435 viewport,
1436 &[],
1437 &std::collections::HashMap::new(),
1438 &[pc],
1439 view_proj,
1440 viewport,
1441 );
1442
1443 assert!(!result.is_empty(), "expected point cloud hits");
1444 let hits = result.hits.get(&99).expect("expected hits for id 99");
1445 assert_eq!(
1446 hits.len(),
1447 2,
1448 "both points should be inside the full-screen rect"
1449 );
1450 assert!(
1452 hits.iter().all(|s| s.is_point()),
1453 "expected SubObjectRef::Point entries"
1454 );
1455 assert_eq!(hits[0], SubObjectRef::Point(0));
1456 assert_eq!(hits[1], SubObjectRef::Point(1));
1457 }
1458
1459 #[test]
1460 fn test_pick_rect_skips_invisible() {
1461 let (positions, indices) = unit_cube_mesh();
1462 let mut mesh_lookup: std::collections::HashMap<usize, (Vec<[f32; 3]>, Vec<u32>)> =
1463 std::collections::HashMap::new();
1464 mesh_lookup.insert(0, (positions, indices));
1465
1466 let item = crate::renderer::SceneRenderItem {
1467 mesh_id: crate::resources::mesh_store::MeshId(0),
1468 model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1469 visible: false, ..Default::default()
1471 };
1472
1473 let view_proj = make_view_proj();
1474 let viewport = glam::Vec2::new(800.0, 600.0);
1475
1476 let result = pick_rect(
1477 glam::Vec2::ZERO,
1478 viewport,
1479 &[item],
1480 &mesh_lookup,
1481 &[],
1482 view_proj,
1483 viewport,
1484 );
1485
1486 assert!(result.is_empty(), "invisible items should be skipped");
1487 }
1488
1489 #[test]
1490 fn test_pick_rect_result_type() {
1491 let mut r = RectPickResult::default();
1493 assert!(r.is_empty());
1494 assert_eq!(r.total_count(), 0);
1495
1496 r.hits.insert(
1497 1,
1498 vec![
1499 SubObjectRef::Face(0),
1500 SubObjectRef::Face(1),
1501 SubObjectRef::Face(2),
1502 ],
1503 );
1504 r.hits.insert(2, vec![SubObjectRef::Point(5)]);
1505 assert!(!r.is_empty());
1506 assert_eq!(r.total_count(), 4);
1507 }
1508
1509 #[test]
1510 fn test_barycentric_at_vertices() {
1511 let a = glam::Vec3::new(0.0, 0.0, 0.0);
1512 let b = glam::Vec3::new(1.0, 0.0, 0.0);
1513 let c = glam::Vec3::new(0.0, 1.0, 0.0);
1514
1515 let (u, v, w) = super::barycentric(a, a, b, c);
1517 assert!((u - 1.0).abs() < 1e-5, "u={u}");
1518 assert!(v.abs() < 1e-5, "v={v}");
1519 assert!(w.abs() < 1e-5, "w={w}");
1520
1521 let (u, v, w) = super::barycentric(b, a, b, c);
1523 assert!(u.abs() < 1e-5, "u={u}");
1524 assert!((v - 1.0).abs() < 1e-5, "v={v}");
1525 assert!(w.abs() < 1e-5, "w={w}");
1526
1527 let centroid = (a + b + c) / 3.0;
1529 let (u, v, w) = super::barycentric(centroid, a, b, c);
1530 assert!((u - 1.0 / 3.0).abs() < 1e-4, "u={u}");
1531 assert!((v - 1.0 / 3.0).abs() < 1e-4, "v={v}");
1532 assert!((w - 1.0 / 3.0).abs() < 1e-4, "w={w}");
1533 }
1534
1535 fn make_volume_item(
1540 bbox_min: [f32; 3],
1541 bbox_max: [f32; 3],
1542 threshold_min: f32,
1543 threshold_max: f32,
1544 ) -> crate::renderer::VolumeItem {
1545 crate::renderer::VolumeItem {
1546 bbox_min,
1547 bbox_max,
1548 threshold_min,
1549 threshold_max,
1550 ..crate::renderer::VolumeItem::default()
1551 }
1552 }
1553
1554 fn make_volume_data(
1555 dims: [u32; 3],
1556 fill: f32,
1557 ) -> crate::geometry::marching_cubes::VolumeData {
1558 let n = (dims[0] * dims[1] * dims[2]) as usize;
1559 crate::geometry::marching_cubes::VolumeData {
1560 data: vec![fill; n],
1561 dims,
1562 origin: [0.0; 3],
1563 spacing: [1.0; 3],
1564 }
1565 }
1566
1567 #[test]
1568 fn test_pick_volume_basic_hit() {
1569 let item = make_volume_item([0.0; 3], [3.0, 3.0, 3.0], 0.5, 1.0);
1572 let volume = make_volume_data([3, 3, 3], 0.8);
1573
1574 let hit = super::pick_volume_cpu(
1575 glam::Vec3::new(1.5, 10.0, 1.5),
1576 glam::Vec3::new(0.0, -1.0, 0.0),
1577 42,
1578 &item,
1579 &volume,
1580 );
1581 assert!(hit.is_some(), "expected a hit");
1582 let hit = hit.unwrap();
1583
1584 assert_eq!(hit.id, 42);
1585 assert_eq!(hit.scalar_value, Some(0.8));
1586
1587 let flat = hit.sub_object.unwrap().index();
1589 let nx = 3u32;
1590 let ny = 3u32;
1591 let ix = flat % nx;
1592 let iy = (flat / nx) % ny;
1593 let iz = flat / (nx * ny);
1594 assert_eq!((ix, iy, iz), (1, 2, 1), "expected top-centre voxel");
1595
1596 assert!(hit.world_pos.y > 2.9, "world_pos.y={}", hit.world_pos.y);
1598
1599 assert!(hit.normal.y > 0.9, "normal={:?}", hit.normal);
1601 }
1602
1603 #[test]
1604 fn test_pick_volume_miss_aabb() {
1605 let item = make_volume_item([0.0; 3], [1.0; 3], 0.0, 1.0);
1606 let volume = make_volume_data([4, 4, 4], 0.5);
1607
1608 let hit = super::pick_volume_cpu(
1610 glam::Vec3::new(10.0, 5.0, 0.5),
1611 glam::Vec3::new(0.0, -1.0, 0.0),
1612 1,
1613 &item,
1614 &volume,
1615 );
1616 assert!(hit.is_none(), "expected miss");
1617 }
1618
1619 #[test]
1620 fn test_pick_volume_threshold_miss() {
1621 let item = make_volume_item([0.0; 3], [1.0; 3], 0.5, 1.0);
1623 let volume = make_volume_data([4, 4, 4], 0.3);
1624
1625 let hit = super::pick_volume_cpu(
1626 glam::Vec3::new(0.5, 5.0, 0.5),
1627 glam::Vec3::new(0.0, -1.0, 0.0),
1628 1,
1629 &item,
1630 &volume,
1631 );
1632 assert!(hit.is_none(), "expected no hit when all scalars below threshold");
1633 }
1634
1635 #[test]
1636 fn test_pick_volume_threshold_skip() {
1637 let item = make_volume_item([0.0; 3], [1.0, 3.0, 1.0], 0.5, 1.0);
1642 let mut volume = make_volume_data([1, 3, 1], 0.0);
1643 volume.data[2] = 0.3;
1645 volume.data[1] = 0.8;
1646 volume.data[0] = 0.8;
1647
1648 let hit = super::pick_volume_cpu(
1649 glam::Vec3::new(0.5, 10.0, 0.5),
1650 glam::Vec3::new(0.0, -1.0, 0.0),
1651 1,
1652 &item,
1653 &volume,
1654 );
1655 assert!(hit.is_some(), "expected a hit");
1656 let hit = hit.unwrap();
1657 let flat = hit.sub_object.unwrap().index();
1658 assert_eq!(flat, 1, "expected iy=1 (flat=1), got flat={flat}");
1659 assert_eq!(hit.scalar_value, Some(0.8));
1660 }
1661
1662 #[test]
1663 fn test_pick_volume_nan_skip() {
1664 let item = make_volume_item([0.0; 3], [1.0, 2.0, 1.0], 0.0, 1.0);
1667 let mut volume = make_volume_data([1, 2, 1], 0.0);
1668 volume.data[1] = f32::NAN;
1669 volume.data[0] = 0.5;
1670
1671 let hit = super::pick_volume_cpu(
1672 glam::Vec3::new(0.5, 10.0, 0.5),
1673 glam::Vec3::new(0.0, -1.0, 0.0),
1674 1,
1675 &item,
1676 &volume,
1677 );
1678 assert!(hit.is_some(), "expected hit after NaN skip");
1679 let hit = hit.unwrap();
1680 assert_eq!(hit.sub_object.unwrap().index(), 0, "expected iy=0 (flat=0)");
1681 assert_eq!(hit.scalar_value, Some(0.5));
1682 }
1683
1684 #[test]
1685 fn test_pick_volume_dda_no_skip() {
1686 let item = make_volume_item([0.0; 3], [10.0, 1.0, 1.0], 0.5, 1.0);
1690 let mut volume = make_volume_data([10, 1, 1], 0.0);
1691 volume.data[9] = 0.8;
1692
1693 let dir = glam::Vec3::new(1.0, 0.0, 0.001).normalize();
1694 let hit = super::pick_volume_cpu(
1695 glam::Vec3::new(-1.0, 0.5, 0.5),
1696 dir,
1697 1,
1698 &item,
1699 &volume,
1700 );
1701 assert!(hit.is_some(), "DDA must reach the last voxel without skipping");
1702 let flat = hit.unwrap().sub_object.unwrap().index();
1703 assert_eq!(flat, 9, "expected ix=9 (flat=9), got flat={flat}");
1704 }
1705
1706 #[test]
1707 fn test_voxel_world_aabb_identity() {
1708 let item = make_volume_item([0.0; 3], [4.0, 4.0, 4.0], 0.0, 1.0);
1710 let volume = make_volume_data([4, 4, 4], 0.0);
1711
1712 let (lo, hi) = super::voxel_world_aabb(0, &volume, &item);
1714 assert!((lo - glam::Vec3::ZERO).length() < 1e-5, "lo={lo:?}");
1715 assert!((hi - glam::Vec3::ONE).length() < 1e-5, "hi={hi:?}");
1716
1717 let (lo, hi) = super::voxel_world_aabb(1, &volume, &item);
1719 assert!((lo.x - 1.0).abs() < 1e-5 && (hi.x - 2.0).abs() < 1e-5);
1720
1721 let (lo, hi) = super::voxel_world_aabb(57, &volume, &item);
1723 assert!((lo - glam::Vec3::new(1.0, 2.0, 3.0)).length() < 1e-5, "lo={lo:?}");
1724 assert!((hi - glam::Vec3::new(2.0, 3.0, 4.0)).length() < 1e-5, "hi={hi:?}");
1725 }
1726
1727 #[test]
1728 fn test_voxel_world_aabb_round_trip() {
1729 let item = make_volume_item([0.0; 3], [3.0, 3.0, 3.0], 0.5, 1.0);
1732 let volume = make_volume_data([3, 3, 3], 0.8);
1733
1734 let hit = super::pick_volume_cpu(
1735 glam::Vec3::new(1.5, 10.0, 1.5),
1736 glam::Vec3::new(0.0, -1.0, 0.0),
1737 1,
1738 &item,
1739 &volume,
1740 )
1741 .expect("expected a hit for round-trip test");
1742
1743 let flat = hit.sub_object.unwrap().index();
1744 let (lo, hi) = super::voxel_world_aabb(flat, &volume, &item);
1745
1746 let tol = 1e-3;
1747 assert!(
1748 hit.world_pos.x >= lo.x - tol && hit.world_pos.x <= hi.x + tol,
1749 "world_pos.x={} outside [{}, {}]",
1750 hit.world_pos.x,
1751 lo.x,
1752 hi.x
1753 );
1754 assert!(
1755 hit.world_pos.y >= lo.y - tol && hit.world_pos.y <= hi.y + tol,
1756 "world_pos.y={} outside [{}, {}]",
1757 hit.world_pos.y,
1758 lo.y,
1759 hi.y
1760 );
1761 assert!(
1762 hit.world_pos.z >= lo.z - tol && hit.world_pos.z <= hi.z + tol,
1763 "world_pos.z={} outside [{}, {}]",
1764 hit.world_pos.z,
1765 lo.z,
1766 hi.z
1767 );
1768 }
1769}