Skip to main content

volren_core/
picking.rs

1//! CPU-based ray-volume intersection for picking.
2
3use glam::{DVec2, DVec3, DVec4, UVec2};
4
5use crate::camera::Camera;
6use crate::math::Aabb;
7use crate::render_params::VolumeRenderParams;
8use crate::volume::{DynVolume, VolumeInfo};
9
10/// A ray in world space.
11#[derive(Debug, Clone, Copy)]
12pub struct Ray {
13    /// Ray origin.
14    pub origin: DVec3,
15    /// Normalised ray direction.
16    pub direction: DVec3,
17}
18
19impl Ray {
20    /// Create a new ray. `direction` is normalised internally.
21    #[must_use]
22    pub fn new(origin: DVec3, direction: DVec3) -> Self {
23        Self {
24            origin,
25            direction: direction.normalize(),
26        }
27    }
28
29    /// Evaluate the ray at parameter `t`.
30    #[must_use]
31    pub fn at(&self, t: f64) -> DVec3 {
32        self.origin + self.direction * t
33    }
34
35    /// Intersect the ray with an axis-aligned bounding box.
36    ///
37    /// Returns `(t_enter, t_exit)` if the ray intersects the box, or `None` if
38    /// it misses. `t_enter` may be negative if the ray starts inside the box.
39    #[must_use]
40    pub fn intersect_aabb(&self, aabb: &Aabb) -> Option<(f64, f64)> {
41        let inv_dir = DVec3::new(
42            1.0 / self.direction.x,
43            1.0 / self.direction.y,
44            1.0 / self.direction.z,
45        );
46
47        let t1 = (aabb.min - self.origin) * inv_dir;
48        let t2 = (aabb.max - self.origin) * inv_dir;
49
50        let t_min = t1.min(t2);
51        let t_max = t1.max(t2);
52
53        let t_enter = t_min.x.max(t_min.y).max(t_min.z);
54        let t_exit = t_max.x.min(t_max.y).min(t_max.z);
55
56        if t_enter <= t_exit && t_exit >= 0.0 {
57            Some((t_enter, t_exit))
58        } else {
59            None
60        }
61    }
62}
63
64/// Unproject a screen-space pixel coordinate into a world-space ray.
65///
66/// `screen_pos` is in pixel coordinates (top-left origin).
67/// `viewport_size` is width × height in pixels.
68#[must_use]
69pub fn unproject_ray(screen_pos: DVec2, camera: &Camera, viewport_size: UVec2) -> Ray {
70    let aspect = f64::from(viewport_size.x) / f64::from(viewport_size.y);
71    let view = camera.view_matrix();
72    let proj = camera.projection_matrix(aspect);
73    let inv_vp = (proj * view).inverse();
74
75    // Normalise to NDC [-1, 1]
76    let ndc_x = (screen_pos.x / f64::from(viewport_size.x)) * 2.0 - 1.0;
77    let ndc_y = 1.0 - (screen_pos.y / f64::from(viewport_size.y)) * 2.0;
78
79    let near_clip = inv_vp * DVec4::new(ndc_x, ndc_y, -1.0, 1.0);
80    let far_clip = inv_vp * DVec4::new(ndc_x, ndc_y, 1.0, 1.0);
81
82    let near_world = near_clip.truncate() / near_clip.w;
83    let far_world = far_clip.truncate() / far_clip.w;
84
85    Ray::new(near_world, far_world - near_world)
86}
87
88/// Result of picking a point on a volume.
89#[derive(Debug, Clone)]
90pub struct PickResult {
91    /// World-space position of the hit point.
92    pub world_position: DVec3,
93    /// Continuous voxel index (fractional) of the hit point.
94    pub voxel_index: DVec3,
95    /// Interpolated scalar value at the hit point.
96    pub voxel_value: f64,
97}
98
99/// Cast a ray from screen coordinates through the volume.
100///
101/// Marches along the ray in small steps (determined by `params.step_size_factor`)
102/// and returns the first hit where the opacity transfer function maps to
103/// a non-trivial value (> 0.01).
104///
105/// Returns `None` if no hit is found.
106#[must_use]
107pub fn pick_volume(
108    screen_pos: DVec2,
109    camera: &Camera,
110    viewport_size: UVec2,
111    volume: &DynVolume,
112    params: &VolumeRenderParams,
113) -> Option<PickResult> {
114    let ray = unproject_ray(screen_pos, camera, viewport_size);
115    let bounds = volume.world_bounds();
116
117    let (t_enter, t_exit) = ray.intersect_aabb(&bounds)?;
118    let t_start = t_enter.max(0.0);
119
120    // Step size in world units — fraction of the smallest voxel dimension
121    let spacing = volume.spacing();
122    let min_spacing = spacing.x.min(spacing.y).min(spacing.z);
123    let step = min_spacing * f64::from(params.step_size_factor);
124
125    let mut t = t_start;
126    while t <= t_exit {
127        let world_pos = ray.at(t);
128        let voxel_idx = volume.world_to_index(world_pos);
129
130        if let Some(value) = volume.sample_linear(voxel_idx) {
131            let opacity = params.opacity_tf.evaluate(value);
132
133            if opacity > 0.01 {
134                return Some(PickResult {
135                    world_position: world_pos,
136                    voxel_index: voxel_idx,
137                    voxel_value: value,
138                });
139            }
140        }
141
142        t += step;
143    }
144
145    None
146}
147
148// ── Tests ─────────────────────────────────────────────────────────────────────
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use approx::assert_abs_diff_eq;
154
155    #[test]
156    fn ray_intersect_aabb_hit() {
157        let ray = Ray::new(DVec3::new(-5.0, 0.5, 0.5), DVec3::X);
158        let aabb = Aabb::new(DVec3::ZERO, DVec3::ONE);
159        let (t_enter, t_exit) = ray.intersect_aabb(&aabb).expect("should hit");
160        assert_abs_diff_eq!(t_enter, 5.0, epsilon = 1e-10);
161        assert_abs_diff_eq!(t_exit, 6.0, epsilon = 1e-10);
162    }
163
164    #[test]
165    fn ray_intersect_aabb_miss() {
166        let ray = Ray::new(DVec3::new(-5.0, 5.0, 0.5), DVec3::X);
167        let aabb = Aabb::new(DVec3::ZERO, DVec3::ONE);
168        assert!(ray.intersect_aabb(&aabb).is_none());
169    }
170
171    #[test]
172    fn ray_intersect_aabb_inside() {
173        let ray = Ray::new(DVec3::new(0.5, 0.5, 0.5), DVec3::X);
174        let aabb = Aabb::new(DVec3::ZERO, DVec3::ONE);
175        let (t_enter, t_exit) = ray.intersect_aabb(&aabb).expect("should hit");
176        assert!(
177            t_enter < 0.0,
178            "origin is inside, t_enter should be negative"
179        );
180        assert!(t_exit > 0.0);
181    }
182
183    #[test]
184    fn ray_at() {
185        let ray = Ray::new(DVec3::ZERO, DVec3::X);
186        let p = ray.at(3.0);
187        assert_abs_diff_eq!(p.x, 3.0, epsilon = 1e-10);
188        assert_abs_diff_eq!(p.y, 0.0, epsilon = 1e-10);
189    }
190
191    #[test]
192    fn unproject_center_looks_forward() {
193        let cam = Camera::new(DVec3::new(0.0, 0.0, 5.0), DVec3::ZERO, DVec3::Y);
194        let viewport = UVec2::new(800, 600);
195        let ray = unproject_ray(DVec2::new(400.0, 300.0), &cam, viewport);
196        // Ray should point roughly towards -Z (toward focal point)
197        assert!(ray.direction.z < 0.0, "ray should point toward -Z");
198        assert_abs_diff_eq!(ray.direction.x, 0.0, epsilon = 0.01);
199        assert_abs_diff_eq!(ray.direction.y, 0.0, epsilon = 0.01);
200    }
201}