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}