Skip to main content

roxlap_scene/
addr.rs

1//! Address math for the world ↔ grid-local ↔ chunk + voxel
2//! coordinate spaces.
3//!
4//! Three spaces:
5//!
6//! 1. **World** (f64). Universe-level positions; one [`DVec3`]
7//!    per point.
8//! 2. **Grid-local** (f64). Position in a grid's local frame
9//!    (origin + rotation already applied). Voxel size is fixed
10//!    at 1 unit / voxel; integer voxel coordinate `v` covers
11//!    grid-local space `[v, v+1)` on each axis.
12//! 3. **Chunk + voxel-in-chunk** (i32 + u32). A grid-local voxel
13//!    coordinate `v: IVec3` decomposes into a chunk index
14//!    `c: IVec3` and a voxel offset `u: UVec3` within that
15//!    chunk. Chunks are XY = [`CHUNK_SIZE_XY`], Z =
16//!    [`CHUNK_SIZE_Z`], so `u.x, u.y < CHUNK_SIZE_XY` and
17//!    `u.z < CHUNK_SIZE_Z`.
18//!
19//! Negative voxel coords decompose with [`i32::div_euclid`] /
20//! [`i32::rem_euclid`] semantics: voxel `-1` lives in chunk `-1`
21//! at position `(CHUNK_SIZE - 1)`. This matches the natural
22//! "voxel slots tile the integer line, chunks tile groups of
23//! slots" intuition; using truncating division would put voxel
24//! `-1` in chunk `0` at position `-1`, splitting the chunk-0 /
25//! chunk-(-1) boundary inconsistently.
26//!
27//! All conversions go through these helpers — risk R5 in
28//! `PORTING-SCENE.md` calls out the f64↔i32 boundary as a common
29//! off-by-one source, so concentrating the casts here lets the
30//! property tests pin them.
31
32// `CHUNK_SIZE_XY` (128) and `CHUNK_SIZE_Z` (256) both fit in
33// i32::MAX/2 with room to spare, so all `as i32` casts in this
34// module are exact. Same for `voxel_in_chunk: UVec3` components,
35// which are bounded by those constants and only cast for
36// arithmetic on signed `IVec3`.
37#![allow(clippy::cast_possible_wrap)]
38
39use glam::{DVec3, IVec3, UVec3, Vec3};
40
41use crate::{GridTransform, CHUNK_SIZE_XY, CHUNK_SIZE_Z};
42
43/// Decomposition of a grid-local position into discrete chunk +
44/// voxel + sub-voxel coordinates.
45///
46/// `chunk + voxel` reconstructs the integer voxel coordinate via
47/// [`voxel_global`]; adding `fract` (range `[0, 1)` per axis) and
48/// the rotated origin gets back to the original world position
49/// via [`grid_local_to_world`]. `fract` is f32 because per-chunk
50/// ray math is f32 throughout — keeping the boundary cast here
51/// means downstream code doesn't repeat it.
52#[derive(Debug, Clone, Copy, PartialEq)]
53pub struct GridLocalPos {
54    /// Chunk index in grid-local space. Signed because chunks
55    /// can extend in either direction from the grid origin.
56    pub chunk: IVec3,
57    /// Voxel offset within `chunk`. `voxel.x, voxel.y` are in
58    /// `[0, CHUNK_SIZE_XY)`; `voxel.z` is in `[0, CHUNK_SIZE_Z)`.
59    pub voxel: UVec3,
60    /// Sub-voxel position within the voxel cell, `[0, 1)` per
61    /// axis. Cast to f32 because per-chunk ray math is f32.
62    pub fract: Vec3,
63}
64
65/// Per-axis chunk size as an [`IVec3`]. Used as the divisor for
66/// `div_euclid` / `rem_euclid` when splitting a grid-local voxel
67/// coordinate into chunk + voxel-in-chunk.
68#[inline]
69fn chunk_size_ivec3() -> IVec3 {
70    // `as i32` is exact: both constants are well under `i32::MAX`.
71    #[allow(clippy::cast_possible_wrap)]
72    IVec3::new(
73        CHUNK_SIZE_XY as i32,
74        CHUNK_SIZE_XY as i32,
75        CHUNK_SIZE_Z as i32,
76    )
77}
78
79/// Split a grid-local voxel coordinate into `(chunk, voxel-in-chunk)`.
80///
81/// Uses [`IVec3::div_euclid`] / [`IVec3::rem_euclid`] so negative
82/// voxel coordinates round toward `-∞`: voxel `-1` decomposes to
83/// `(chunk = -1, voxel = CHUNK_SIZE - 1)`, not `(0, -1)`. The
84/// returned `voxel-in-chunk` is always non-negative, so casting
85/// each component `as u32` is safe.
86#[must_use]
87pub fn voxel_split(voxel: IVec3) -> (IVec3, UVec3) {
88    let cs = chunk_size_ivec3();
89    let chunk = voxel.div_euclid(cs);
90    let in_chunk_i = voxel.rem_euclid(cs);
91    // rem_euclid postcondition: each component in [0, divisor).
92    // Cast is safe.
93    #[allow(clippy::cast_sign_loss)]
94    let in_chunk = UVec3::new(
95        in_chunk_i.x as u32,
96        in_chunk_i.y as u32,
97        in_chunk_i.z as u32,
98    );
99    (chunk, in_chunk)
100}
101
102/// Inverse of [`voxel_split`]: combine a chunk index and a
103/// voxel-in-chunk offset back into a grid-local voxel coordinate.
104///
105/// Caller is responsible for the `voxel_in_chunk` invariant
106/// (`x, y < CHUNK_SIZE_XY` and `z < CHUNK_SIZE_Z`); a stray
107/// out-of-range value just shifts the result by a chunk's worth.
108/// Debug builds panic via the [`debug_assert!`]s.
109#[must_use]
110pub fn voxel_global(chunk: IVec3, voxel_in_chunk: UVec3) -> IVec3 {
111    debug_assert!(voxel_in_chunk.x < CHUNK_SIZE_XY, "voxel.x out of range");
112    debug_assert!(voxel_in_chunk.y < CHUNK_SIZE_XY, "voxel.y out of range");
113    debug_assert!(voxel_in_chunk.z < CHUNK_SIZE_Z, "voxel.z out of range");
114    let cs = chunk_size_ivec3();
115    // `as i32` is safe: voxel_in_chunk components are < CHUNK_SIZE_*,
116    // which fit comfortably in i32.
117    #[allow(clippy::cast_possible_wrap)]
118    let in_chunk_i = IVec3::new(
119        voxel_in_chunk.x as i32,
120        voxel_in_chunk.y as i32,
121        voxel_in_chunk.z as i32,
122    );
123    chunk * cs + in_chunk_i
124}
125
126/// Project a world-space position into the grid-local frame and
127/// decompose into chunk + voxel + sub-voxel.
128///
129/// Steps:
130/// 1. Translate by `-transform.origin`.
131/// 2. Rotate by `transform.rotation.inverse()` (back to grid-local
132///    axes). Identity rotation (axis-aligned grid) collapses this
133///    to a no-op.
134/// 3. Floor each component to the integer voxel; keep the
135///    remainder as the sub-voxel fractional position (cast to
136///    f32 at the boundary).
137/// 4. Split the integer voxel into chunk + voxel-in-chunk via
138///    [`voxel_split`].
139///
140/// The full pipeline is the canonical world↔grid handoff — risk
141/// R5 in `PORTING-SCENE.md`. Round-tripping with
142/// [`grid_local_to_world`] reconstructs the original world point
143/// up to f32 precision in the fractional component (sub-millimetre
144/// at typical voxel scales).
145#[must_use]
146pub fn world_to_grid_local(world_pos: DVec3, transform: &GridTransform) -> GridLocalPos {
147    let local_d = transform.rotation.inverse() * (world_pos - transform.origin);
148    let voxel_d = local_d.floor();
149    // After `.floor()` the components are integer-valued; truncating
150    // to i32 is equivalent to flooring. Out-of-range coords saturate,
151    // which is acceptable behaviour for a degenerate input — the
152    // caller never sees a wrap. f32 fractional cast is lossy by
153    // design; sub-mm precision at 1-unit voxel scale.
154    #[allow(
155        clippy::cast_possible_truncation,
156        clippy::cast_precision_loss,
157        clippy::cast_sign_loss
158    )]
159    let voxel = IVec3::new(voxel_d.x as i32, voxel_d.y as i32, voxel_d.z as i32);
160    #[allow(clippy::cast_possible_truncation)]
161    let fract = (local_d - voxel_d).as_vec3();
162    let (chunk, in_chunk) = voxel_split(voxel);
163    GridLocalPos {
164        chunk,
165        voxel: in_chunk,
166        fract,
167    }
168}
169
170/// Inverse of [`world_to_grid_local`]: reconstruct the world-space
171/// position of a grid-local chunk + voxel + sub-voxel.
172///
173/// Round-trips with [`world_to_grid_local`] up to f32 precision in
174/// the fractional component (the `fract: Vec3` cast is the lossy
175/// step).
176#[must_use]
177pub fn grid_local_to_world(
178    chunk: IVec3,
179    voxel_in_chunk: UVec3,
180    fract: Vec3,
181    transform: &GridTransform,
182) -> DVec3 {
183    let voxel = voxel_global(chunk, voxel_in_chunk);
184    let local = voxel.as_dvec3() + fract.as_dvec3();
185    transform.origin + transform.rotation * local
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use glam::DQuat;
192
193    // ---- voxel_split / voxel_global round-trip ----
194
195    #[test]
196    fn voxel_split_origin() {
197        let (c, v) = voxel_split(IVec3::ZERO);
198        assert_eq!(c, IVec3::ZERO);
199        assert_eq!(v, UVec3::ZERO);
200    }
201
202    #[test]
203    fn voxel_split_at_chunk_boundary_positive() {
204        // (CHUNK_SIZE_XY, 0, 0) is the first voxel of chunk (1,0,0).
205        let (c, v) = voxel_split(IVec3::new(CHUNK_SIZE_XY as i32, 0, 0));
206        assert_eq!(c, IVec3::new(1, 0, 0));
207        assert_eq!(v, UVec3::new(0, 0, 0));
208    }
209
210    #[test]
211    fn voxel_split_at_chunk_boundary_minus_one() {
212        // (-1, 0, 0) is the last voxel of chunk (-1, 0, 0).
213        let (c, v) = voxel_split(IVec3::new(-1, 0, 0));
214        assert_eq!(c, IVec3::new(-1, 0, 0));
215        assert_eq!(v, UVec3::new(CHUNK_SIZE_XY - 1, 0, 0));
216    }
217
218    #[test]
219    fn voxel_split_z_axis_uses_z_chunk_size() {
220        // z = 256 should fall into chunk (0, 0, 1) at z-offset 0,
221        // not into a 128-strided chunk like XY.
222        let (c, v) = voxel_split(IVec3::new(0, 0, CHUNK_SIZE_Z as i32));
223        assert_eq!(c, IVec3::new(0, 0, 1));
224        assert_eq!(v, UVec3::new(0, 0, 0));
225    }
226
227    #[test]
228    fn voxel_global_inverts_voxel_split() {
229        // Sample chunks/voxels covering positive, negative,
230        // boundary, and interior cases. Each should round-trip.
231        let cases = [
232            IVec3::ZERO,
233            IVec3::new(1, 1, 1),
234            IVec3::new(-1, 0, 0),
235            IVec3::new(0, -1, 0),
236            IVec3::new(0, 0, -1),
237            IVec3::new(CHUNK_SIZE_XY as i32, 0, 0),
238            IVec3::new(0, CHUNK_SIZE_XY as i32, 0),
239            IVec3::new(0, 0, CHUNK_SIZE_Z as i32),
240            IVec3::new(-(CHUNK_SIZE_XY as i32) - 5, 7, 33),
241            IVec3::new(127, 128, 256),
242            IVec3::new(1_000_000, -1_000_000, 500),
243        ];
244        for v in cases {
245            let (c, in_chunk) = voxel_split(v);
246            assert_eq!(
247                voxel_global(c, in_chunk),
248                v,
249                "round trip failed for v={v:?} → (c={c:?}, in_chunk={in_chunk:?})"
250            );
251        }
252    }
253
254    #[test]
255    fn voxel_split_in_chunk_always_in_range() {
256        // Brute-force a 3-chunk-wide range around the origin so we
257        // hit positive, negative, and boundary voxels on all axes.
258        for vx in -200i32..200 {
259            for vy in -200i32..200 {
260                for vz in -300i32..300 {
261                    let (_, u) = voxel_split(IVec3::new(vx, vy, vz));
262                    assert!(u.x < CHUNK_SIZE_XY, "x={} out of range", u.x);
263                    assert!(u.y < CHUNK_SIZE_XY, "y={} out of range", u.y);
264                    assert!(u.z < CHUNK_SIZE_Z, "z={} out of range", u.z);
265                }
266            }
267        }
268    }
269
270    // ---- world_to_grid_local: identity transform ----
271
272    #[test]
273    fn world_to_local_identity_at_origin() {
274        let t = GridTransform::identity();
275        let p = world_to_grid_local(DVec3::ZERO, &t);
276        assert_eq!(p.chunk, IVec3::ZERO);
277        assert_eq!(p.voxel, UVec3::ZERO);
278        assert!(p.fract.abs_diff_eq(Vec3::ZERO, 1e-6));
279    }
280
281    #[test]
282    fn world_to_local_identity_at_voxel_centre() {
283        // (1.5, 2.5, 3.5) is the centre of voxel (1, 2, 3) — chunk
284        // (0, 0, 0), voxel-in-chunk (1, 2, 3), fractional (.5, .5, .5).
285        let t = GridTransform::identity();
286        let p = world_to_grid_local(DVec3::new(1.5, 2.5, 3.5), &t);
287        assert_eq!(p.chunk, IVec3::ZERO);
288        assert_eq!(p.voxel, UVec3::new(1, 2, 3));
289        assert!(p.fract.abs_diff_eq(Vec3::splat(0.5), 1e-6));
290    }
291
292    #[test]
293    fn world_to_local_negative_world_pos() {
294        // (-0.5, 0, 0) sits in voxel (-1, 0, 0) = chunk (-1, 0, 0)
295        // at position CHUNK_SIZE_XY - 1, fractional .5.
296        let t = GridTransform::identity();
297        let p = world_to_grid_local(DVec3::new(-0.5, 0.0, 0.0), &t);
298        assert_eq!(p.chunk, IVec3::new(-1, 0, 0));
299        assert_eq!(p.voxel, UVec3::new(CHUNK_SIZE_XY - 1, 0, 0));
300        assert!(p.fract.abs_diff_eq(Vec3::new(0.5, 0.0, 0.0), 1e-6));
301    }
302
303    #[test]
304    fn world_to_local_at_chunk_boundary() {
305        // World x = 128.0 == CHUNK_SIZE_XY: starts a new chunk.
306        let t = GridTransform::identity();
307        let p = world_to_grid_local(DVec3::new(f64::from(CHUNK_SIZE_XY), 0.0, 0.0), &t);
308        assert_eq!(p.chunk, IVec3::new(1, 0, 0));
309        assert_eq!(p.voxel, UVec3::ZERO);
310        assert!(p.fract.abs_diff_eq(Vec3::ZERO, 1e-6));
311    }
312
313    // ---- world_to_local: translated grid ----
314
315    #[test]
316    fn translation_offsets_world_position() {
317        // Grid placed at world (1000, 2000, 3000); world point
318        // (1000.5, 2000.5, 3000.5) is grid-local (0.5, 0.5, 0.5)
319        // → voxel (0, 0, 0), fractional (0.5, 0.5, 0.5).
320        let t = GridTransform::at(DVec3::new(1000.0, 2000.0, 3000.0));
321        let p = world_to_grid_local(DVec3::new(1000.5, 2000.5, 3000.5), &t);
322        assert_eq!(p.chunk, IVec3::ZERO);
323        assert_eq!(p.voxel, UVec3::ZERO);
324        assert!(p.fract.abs_diff_eq(Vec3::splat(0.5), 1e-6));
325    }
326
327    // ---- world_to_local: rotated grid ----
328
329    #[test]
330    fn rotation_90_z_swaps_x_and_y() {
331        // 90° rotation about +z maps grid-local +x to world +y.
332        // World point (0, 5, 0) is grid-local (5, 0, 0): rotation
333        // inverse takes world +y back to grid-local +x.
334        let t = GridTransform {
335            origin: DVec3::ZERO,
336            rotation: DQuat::from_rotation_z(std::f64::consts::FRAC_PI_2),
337        };
338        let p = world_to_grid_local(DVec3::new(0.0, 5.5, 0.0), &t);
339        assert_eq!(p.chunk, IVec3::ZERO);
340        assert_eq!(p.voxel, UVec3::new(5, 0, 0));
341        // Quat math has tiny rounding error — allow 1e-6.
342        assert!(
343            p.fract.abs_diff_eq(Vec3::new(0.5, 0.0, 0.0), 1e-5),
344            "fract={:?} expected ~(0.5, 0, 0)",
345            p.fract
346        );
347    }
348
349    // ---- grid_local_to_world: round-trip ----
350
351    #[test]
352    fn world_local_world_round_trip_identity() {
353        let t = GridTransform::identity();
354        let world = DVec3::new(12.25, -7.75, 200.5);
355        let p = world_to_grid_local(world, &t);
356        let back = grid_local_to_world(p.chunk, p.voxel, p.fract, &t);
357        assert!(
358            back.abs_diff_eq(world, 1e-5),
359            "back={back:?} world={world:?}"
360        );
361    }
362
363    #[test]
364    fn world_local_world_round_trip_translated() {
365        let t = GridTransform::at(DVec3::new(500.0, -250.0, 100.0));
366        let world = DVec3::new(512.25, -260.5, 109.75);
367        let p = world_to_grid_local(world, &t);
368        let back = grid_local_to_world(p.chunk, p.voxel, p.fract, &t);
369        assert!(
370            back.abs_diff_eq(world, 1e-5),
371            "back={back:?} world={world:?}"
372        );
373    }
374
375    #[test]
376    fn world_local_world_round_trip_rotated() {
377        let t = GridTransform {
378            origin: DVec3::new(10.0, 20.0, 30.0),
379            rotation: DQuat::from_rotation_z(0.5).normalize(),
380        };
381        // Sample several points to exercise the rotation math.
382        let samples = [
383            DVec3::new(11.5, 22.5, 33.5),
384            DVec3::new(10.0, 20.0, 30.0),
385            DVec3::new(9.0, 19.0, 29.0),
386        ];
387        for world in samples {
388            let p = world_to_grid_local(world, &t);
389            let back = grid_local_to_world(p.chunk, p.voxel, p.fract, &t);
390            assert!(
391                back.abs_diff_eq(world, 1e-5),
392                "back={back:?} world={world:?}"
393            );
394        }
395    }
396
397    // ---- S5.1: parameterised round-trip over multiple rotations ----
398
399    /// Sweep a grid of rotations × world positions and confirm the
400    /// `world_to_grid_local` ∘ `grid_local_to_world` round-trip
401    /// closes within f32-fract precision (cast from f64 → f32 in
402    /// [`GridLocalPos::fract`] is the lossy step). Per PORTING-SCENE.md
403    /// risk R5, this is the canonical f64↔i32 boundary check.
404    ///
405    /// Tolerance is `1e-5` — empirically matches the worst-case
406    /// f32 precision of a `fract` cast at unit voxel scale (≈ 1.2e-7
407    /// per component, amplified slightly by the round-trip's
408    /// re-rotation).
409    #[test]
410    fn world_local_world_round_trip_rotation_sweep() {
411        // Rotations: identity (sanity), three axis-aligned 90°s, a
412        // shallow tilt, a 45° composite, and a fully arbitrary
413        // axis/angle. Cover identity → near-singular → arbitrary.
414        let rotations = [
415            ("identity", DQuat::IDENTITY),
416            (
417                "90deg-z",
418                DQuat::from_rotation_z(std::f64::consts::FRAC_PI_2),
419            ),
420            (
421                "90deg-y",
422                DQuat::from_rotation_y(std::f64::consts::FRAC_PI_2),
423            ),
424            (
425                "90deg-x",
426                DQuat::from_rotation_x(std::f64::consts::FRAC_PI_2),
427            ),
428            ("180deg-z exact", DQuat::from_xyzw(0.0, 0.0, 1.0, 0.0)),
429            (
430                "45deg-z",
431                DQuat::from_rotation_z(std::f64::consts::FRAC_PI_4),
432            ),
433            (
434                "tilted 0.7rad",
435                DQuat::from_axis_angle(DVec3::new(0.3, 0.8, 0.5).normalize(), 0.7),
436            ),
437            (
438                "yaw+pitch+roll composite",
439                DQuat::from_rotation_y(0.4)
440                    * DQuat::from_rotation_x(0.3)
441                    * DQuat::from_rotation_z(0.2),
442            ),
443        ];
444        // World positions: origin, positive interior, negative
445        // quadrant, near chunk boundaries (positive + negative), and
446        // far-from-origin so the f64 → f32 fract cast loses no
447        // additional precision.
448        let world_positions = [
449            DVec3::ZERO,
450            DVec3::new(1.5, 2.5, 3.5),
451            DVec3::new(-1.5, -2.5, -3.5),
452            DVec3::new(f64::from(CHUNK_SIZE_XY) - 0.01, 0.5, 0.5),
453            DVec3::new(-f64::from(CHUNK_SIZE_XY) - 0.01, 0.5, 0.5),
454            DVec3::new(500.25, -250.75, 100.125),
455        ];
456        // Grid origins: at world origin (canonical case) and a
457        // non-trivial offset to verify the translation interacts
458        // correctly with the rotation.
459        let grid_origins = [DVec3::ZERO, DVec3::new(1000.0, -500.0, 200.0)];
460
461        for (rot_name, rotation) in rotations {
462            for grid_origin in grid_origins {
463                let t = GridTransform {
464                    origin: grid_origin,
465                    rotation,
466                };
467                for world in world_positions {
468                    let p = world_to_grid_local(world, &t);
469                    let back = grid_local_to_world(p.chunk, p.voxel, p.fract, &t);
470                    assert!(
471                        back.abs_diff_eq(world, 1e-5),
472                        "rotation={rot_name} origin={grid_origin:?} world={world:?} back={back:?}"
473                    );
474                }
475            }
476        }
477    }
478
479    /// Spot-check that a non-identity rotation actually places the
480    /// voxel decomposition in a different chunk than the identity
481    /// case would — guards against a regression where rotation is
482    /// silently dropped (e.g., a missed `transform.rotation` field
483    /// read). For 90°-Z about the world origin, world point
484    /// `(0, 5, 0)` lives in grid-local chunk (0, 0, 0) voxel
485    /// `(5, 0, 0)`, NOT the `(0, 5, 0)` it would map to under
486    /// identity.
487    #[test]
488    fn rotated_world_point_lands_in_rotated_voxel() {
489        let t = GridTransform {
490            origin: DVec3::ZERO,
491            rotation: DQuat::from_rotation_z(std::f64::consts::FRAC_PI_2),
492        };
493        let p_rotated = world_to_grid_local(DVec3::new(0.0, 5.5, 0.0), &t);
494        let p_identity = world_to_grid_local(DVec3::new(0.0, 5.5, 0.0), &GridTransform::identity());
495        assert_ne!(
496            p_rotated.voxel, p_identity.voxel,
497            "rotated voxel ({:?}) coincidentally equals identity voxel ({:?}) — rotation may have been dropped",
498            p_rotated.voxel,
499            p_identity.voxel,
500        );
501        assert_eq!(p_rotated.voxel, UVec3::new(5, 0, 0));
502        assert_eq!(p_identity.voxel, UVec3::new(0, 5, 0));
503    }
504}