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