feat(shared): add EntityPos split-coordinate entity position

This commit is contained in:
Serkyo 2026-07-07 18:07:15 +02:00
parent 3b802a542f
commit d0ddc31801

View file

@ -3,6 +3,7 @@
//! Core data structures representing the voxel world.
use bytemuck::{Pod, Zeroable};
use glam::Vec3;
use serde::{Deserialize, Serialize};
/// The size of a chunk along one axis in blocks.
@ -99,9 +100,47 @@ impl ChunkPos {
}
}
/// A precision-safe position of an entity in the world.
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct EntityPos {
/// The chunk containing the entity; the exact integer anchor of the position.
pub chunk: ChunkPos,
/// The offset within `chunk`, measured in blocks.
pub local: Vec3,
}
impl EntityPos {
/// Initializes a new entity position from a chunk anchor and a local offset.
#[must_use]
pub fn new(chunk: ChunkPos, local: Vec3) -> Self {
Self { chunk, local }
}
/// Rebases the position so every component of `local` lies within `[0.0, CHUNK_SIZE)`, carrying any whole-chunk overflow into `chunk`.
#[expect(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
pub fn renormalize(&mut self) {
let size = CHUNK_SIZE as f32;
// Number of whole chunks each axis must carry: the floored quotient of the offset by size.
let carry = (self.local / size).floor();
self.chunk.x += carry.x as i32;
self.chunk.y += carry.y as i32;
self.chunk.z += carry.z as i32;
// Removing the carried chunks leaves each local component within [0.0, size).
self.local -= carry * size;
}
}
#[cfg(test)]
mod tests {
use super::ChunkPos;
use super::{CHUNK_SIZE, ChunkPos, EntityPos};
use glam::Vec3;
// `CHUNK_SIZE` is 32, exactly representable, so the widening cannot lose precision here.
#[expect(clippy::cast_precision_loss)]
const CHUNK_SIZE_F: f32 = CHUNK_SIZE as f32;
#[test]
fn from_world_maps_positive_positions() {
@ -124,4 +163,45 @@ mod tests {
// A position of -0.5 lies inside block -1, which is in chunk -1.
assert_eq!(ChunkPos::from_world(-0.5, 0.0, 0.0).x, -1);
}
#[test]
fn renormalize_leaves_in_range_offsets_untouched() {
// A local offset already inside [0, CHUNK_SIZE) must not move the anchor.
let mut pos = EntityPos::new(ChunkPos::new(1, 2, 3), Vec3::new(5.0, 10.0, 15.0));
pos.renormalize();
assert_eq!(pos.chunk, ChunkPos::new(1, 2, 3));
assert!(pos.local.abs_diff_eq(Vec3::new(5.0, 10.0, 15.0), 1e-6));
}
#[test]
fn renormalize_carries_positive_overflow() {
// One block past the chunk's far edge lands in the next chunk at local 1.0.
let over = CHUNK_SIZE_F + 1.0;
let mut pos = EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::new(over, 0.0, 0.0));
pos.renormalize();
assert_eq!(pos.chunk, ChunkPos::new(1, 0, 0));
assert!(pos.local.abs_diff_eq(Vec3::new(1.0, 0.0, 0.0), 1e-6));
}
#[test]
fn renormalize_borrows_on_negative_offset() {
// The div_euclid analogue: -0.5 must borrow a chunk, not clamp to zero.
let mut pos = EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::new(-0.5, 0.0, 0.0));
pos.renormalize();
assert_eq!(pos.chunk, ChunkPos::new(-1, 0, 0));
assert!(
pos.local
.abs_diff_eq(Vec3::new(CHUNK_SIZE_F - 0.5, 0.0, 0.0), 1e-6)
);
}
#[test]
fn renormalize_carries_multiple_chunks() {
// A large offset carries more than one chunk in a single call.
let far = CHUNK_SIZE_F * 2.0 + 6.0;
let mut pos = EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::new(far, 0.0, 0.0));
pos.renormalize();
assert_eq!(pos.chunk, ChunkPos::new(2, 0, 0));
assert!(pos.local.abs_diff_eq(Vec3::new(6.0, 0.0, 0.0), 1e-6));
}
}