From d0ddc3180161a170db0f795c0a0e522a546d989a Mon Sep 17 00:00:00 2001 From: Serkyo Date: Tue, 7 Jul 2026 18:07:15 +0200 Subject: [PATCH] feat(shared): add EntityPos split-coordinate entity position --- crates/shared/src/world.rs | 82 +++++++++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/crates/shared/src/world.rs b/crates/shared/src/world.rs index 7eb7849..25a2fbe 100644 --- a/crates/shared/src/world.rs +++ b/crates/shared/src/world.rs @@ -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)); + } }