Skip to main content

roxlap_core/
ray_aabb.rs

1//! S1.1 — ray ↔ axis-aligned bounding box clip helper.
2//!
3//! Standard slab-method intersection. Used by the S1 outside-camera
4//! dispatch (`opticast.rs`) to find the t-parameter at which an
5//! outside-the-grid camera ray first enters the world's column AABB,
6//! so gline can be initialised at that face hit instead of at the
7//! camera origin (which sits past `vsid`).
8//!
9//! Will also be reused by S5 (per-grid rotation: each grid's
10//! grid-local ray gets clipped against the grid's local AABB before
11//! the cross-chunk DDA starts) and S6 (LOD selection: distance to
12//! the AABB is part of the near/mid/far classifier).
13
14/// Intersect a ray `origin + t * dir` against the axis-aligned box
15/// `[aabb_min, aabb_max]` and return `Some((t_enter, t_exit))` if
16/// the ray hits the box at any t, else `None`.
17///
18/// Semantics:
19/// - `t_enter <= t_exit` always when `Some`.
20/// - Origin **inside** the box → `t_enter <= 0.0 <= t_exit`. Caller
21///   uses `t_enter <= 0` as the "camera is already inside, take the
22///   normal path" signal.
23/// - Origin **on a face** → `t_enter == 0` if dir points inward,
24///   else `t_exit == 0` (degenerate graze — caller decides).
25/// - Ray parallel to a slab while origin is outside that slab →
26///   `None`. Ray parallel and origin inside that slab → that axis
27///   doesn't constrain; other axes still do.
28/// - Ray points entirely away from a hit-able box (`t_exit < 0`) →
29///   `None`.
30///
31/// Robust against `dir == 0` per axis (no NaN from `0.0 / 0.0`).
32/// `dir` does not need to be normalised; `t_enter / t_exit` are in
33/// the same units as `1.0 / |dir|` so a unit-length dir gives a
34/// `t` that's a world-space distance.
35#[must_use]
36pub fn clip_ray_to_aabb(
37    origin: [f32; 3],
38    dir: [f32; 3],
39    aabb_min: [f32; 3],
40    aabb_max: [f32; 3],
41) -> Option<(f32, f32)> {
42    let mut t_enter: f32 = f32::NEG_INFINITY;
43    let mut t_exit: f32 = f32::INFINITY;
44    for axis in 0..3 {
45        let o = origin[axis];
46        let d = dir[axis];
47        let lo = aabb_min[axis];
48        let hi = aabb_max[axis];
49        if d == 0.0 {
50            if o < lo || o > hi {
51                return None;
52            }
53            continue;
54        }
55        let inv = 1.0 / d;
56        let (t1, t2) = {
57            let a = (lo - o) * inv;
58            let b = (hi - o) * inv;
59            if a <= b {
60                (a, b)
61            } else {
62                (b, a)
63            }
64        };
65        if t1 > t_enter {
66            t_enter = t1;
67        }
68        if t2 < t_exit {
69            t_exit = t2;
70        }
71        if t_enter > t_exit {
72            return None;
73        }
74    }
75    if t_exit < 0.0 {
76        return None;
77    }
78    Some((t_enter, t_exit))
79}
80
81/// 2D slice of [`clip_ray_to_aabb`] in the XY plane only — what S1.2
82/// actually needs at the dispatch level. Voxlap's column grid is
83/// indexed by `(cx, cy)`; Z is handled inside each column's slab
84/// data, so the per-frame outside check only cares about whether a
85/// ray ever enters the XY footprint of the grid.
86#[must_use]
87pub fn clip_ray_to_xy_aabb(
88    origin_xy: [f32; 2],
89    dir_xy: [f32; 2],
90    aabb_min_xy: [f32; 2],
91    aabb_max_xy: [f32; 2],
92) -> Option<(f32, f32)> {
93    clip_ray_to_aabb(
94        [origin_xy[0], origin_xy[1], 0.0],
95        [dir_xy[0], dir_xy[1], 0.0],
96        [aabb_min_xy[0], aabb_min_xy[1], -1.0],
97        [aabb_max_xy[0], aabb_max_xy[1], 1.0],
98    )
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    const UNIT_BOX_MIN: [f32; 3] = [0.0, 0.0, 0.0];
106    const UNIT_BOX_MAX: [f32; 3] = [1.0, 1.0, 1.0];
107
108    fn approx_eq(a: f32, b: f32) -> bool {
109        (a - b).abs() < 1e-5
110    }
111
112    #[test]
113    fn direct_hit_through_x_face() {
114        let r = clip_ray_to_aabb(
115            [-5.0, 0.5, 0.5],
116            [1.0, 0.0, 0.0],
117            UNIT_BOX_MIN,
118            UNIT_BOX_MAX,
119        )
120        .expect("expected hit");
121        assert!(approx_eq(r.0, 5.0), "t_enter={}", r.0);
122        assert!(approx_eq(r.1, 6.0), "t_exit={}", r.1);
123    }
124
125    #[test]
126    fn parallel_outside_misses() {
127        let r = clip_ray_to_aabb(
128            [-5.0, 5.0, 0.5],
129            [1.0, 0.0, 0.0],
130            UNIT_BOX_MIN,
131            UNIT_BOX_MAX,
132        );
133        assert!(r.is_none());
134    }
135
136    #[test]
137    fn origin_inside_yields_negative_t_enter() {
138        let r = clip_ray_to_aabb([0.5, 0.5, 0.5], [1.0, 0.0, 0.0], UNIT_BOX_MIN, UNIT_BOX_MAX)
139            .expect("expected hit");
140        assert!(approx_eq(r.0, -0.5), "t_enter={}", r.0);
141        assert!(approx_eq(r.1, 0.5), "t_exit={}", r.1);
142        assert!(r.0 <= 0.0);
143    }
144
145    #[test]
146    fn parallel_inside_does_not_constrain() {
147        let r = clip_ray_to_aabb([0.5, 0.5, 0.5], [0.0, 0.0, 1.0], UNIT_BOX_MIN, UNIT_BOX_MAX)
148            .expect("expected hit");
149        assert!(approx_eq(r.0, -0.5));
150        assert!(approx_eq(r.1, 0.5));
151    }
152
153    #[test]
154    fn ray_pointed_away_from_box_misses() {
155        let r = clip_ray_to_aabb(
156            [-1.0, 0.5, 0.5],
157            [-1.0, 0.0, 0.0],
158            UNIT_BOX_MIN,
159            UNIT_BOX_MAX,
160        );
161        assert!(r.is_none(), "got {r:?}");
162    }
163
164    #[test]
165    fn corner_grazing_hit_is_a_hit() {
166        let r = clip_ray_to_aabb(
167            [-1.0, -1.0, 0.5],
168            [1.0, 1.0, 0.0],
169            UNIT_BOX_MIN,
170            UNIT_BOX_MAX,
171        )
172        .expect("corner graze should still be a hit");
173        assert!(approx_eq(r.0, 1.0));
174        assert!(approx_eq(r.1, 2.0));
175    }
176
177    #[test]
178    fn corner_grazing_miss_by_epsilon() {
179        let r = clip_ray_to_aabb(
180            [-1.0, -1.001, 0.5],
181            [1.0, 1.0, 0.0],
182            UNIT_BOX_MIN,
183            UNIT_BOX_MAX,
184        );
185        // Still a hit (very thin sliver at y=0). The "miss by
186        // epsilon" property only fires when the slabs reverse —
187        // confirm that doesn't happen here.
188        assert!(r.is_some());
189    }
190
191    #[test]
192    fn entry_face_origin_returns_zero_t_enter() {
193        let r = clip_ray_to_aabb([0.0, 0.5, 0.5], [1.0, 0.0, 0.0], UNIT_BOX_MIN, UNIT_BOX_MAX)
194            .expect("expected hit");
195        assert!(approx_eq(r.0, 0.0));
196        assert!(approx_eq(r.1, 1.0));
197    }
198
199    #[test]
200    fn exit_face_origin_zero_length_interval() {
201        let r = clip_ray_to_aabb([1.0, 0.5, 0.5], [1.0, 0.0, 0.0], UNIT_BOX_MIN, UNIT_BOX_MAX)
202            .expect("graze of exit face is technically a hit");
203        assert!(approx_eq(r.0, -1.0));
204        assert!(approx_eq(r.1, 0.0));
205    }
206
207    #[test]
208    fn xy_helper_matches_3d_for_xy_ray() {
209        let three_d = clip_ray_to_aabb(
210            [-3.0, 0.5, 0.5],
211            [1.0, 0.5, 0.0],
212            [0.0, 0.0, -1.0],
213            [10.0, 10.0, 1.0],
214        )
215        .expect("3D hit");
216        let two_d =
217            clip_ray_to_xy_aabb([-3.0, 0.5], [1.0, 0.5], [0.0, 0.0], [10.0, 10.0]).expect("2D hit");
218        assert!(approx_eq(three_d.0, two_d.0));
219        assert!(approx_eq(three_d.1, two_d.1));
220    }
221
222    #[test]
223    fn outside_orbit_camera_into_world() {
224        // S1's actual use case: camera at (vsid + 256, vsid/2)
225        // looking back along -X at the world AABB [0, vsid]^2.
226        let vsid = 2048.0;
227        let origin_xy = [vsid + 256.0, vsid * 0.5];
228        let dir_xy = [-1.0, 0.0];
229        let r = clip_ray_to_xy_aabb(origin_xy, dir_xy, [0.0, 0.0], [vsid, vsid])
230            .expect("outside_orbit center ray must hit world AABB");
231        assert!(approx_eq(r.0, 256.0), "t_enter={}", r.0);
232        assert!(approx_eq(r.1, 256.0 + vsid), "t_exit={}", r.1);
233    }
234
235    #[test]
236    fn nan_dir_zero_origin_outside_misses() {
237        // dir=0 on x with origin past the +x face → miss without
238        // generating any NaNs.
239        let r = clip_ray_to_aabb(
240            [10.0, 0.5, 0.5],
241            [0.0, 1.0, 0.0],
242            UNIT_BOX_MIN,
243            UNIT_BOX_MAX,
244        );
245        assert!(r.is_none());
246    }
247}