From b196dc31523cd2b605d47e37f12547bc8ca0719f Mon Sep 17 00:00:00 2001 From: Serkyo Date: Wed, 8 Jul 2026 02:37:19 +0200 Subject: [PATCH] refactor(shared): split world module into per-concept files --- crates/shared/src/world/block.rs | 30 ++++ .../shared/src/{world.rs => world/chunk.rs} | 165 +----------------- crates/shared/src/world/coords.rs | 63 +++++++ crates/shared/src/world/entity.rs | 90 ++++++++++ crates/shared/src/world/mod.rs | 20 +++ 5 files changed, 205 insertions(+), 163 deletions(-) create mode 100644 crates/shared/src/world/block.rs rename crates/shared/src/{world.rs => world/chunk.rs} (60%) create mode 100644 crates/shared/src/world/coords.rs create mode 100644 crates/shared/src/world/entity.rs create mode 100644 crates/shared/src/world/mod.rs diff --git a/crates/shared/src/world/block.rs b/crates/shared/src/world/block.rs new file mode 100644 index 0000000..581086c --- /dev/null +++ b/crates/shared/src/world/block.rs @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Block material identifiers. + +use bytemuck::{Pod, Zeroable}; +use serde::{Deserialize, Serialize}; + +/// A unique identifier representing a type of block in the world. +#[repr(transparent)] +#[derive( + Copy, + Clone, + Debug, + Default, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + Pod, + Zeroable, +)] +pub struct BlockId(pub u16); + +impl BlockId { + /// The block identifier representing empty space. + pub const AIR: BlockId = BlockId(0); +} diff --git a/crates/shared/src/world.rs b/crates/shared/src/world/chunk.rs similarity index 60% rename from crates/shared/src/world.rs rename to crates/shared/src/world/chunk.rs index 5870534..003b60c 100644 --- a/crates/shared/src/world.rs +++ b/crates/shared/src/world/chunk.rs @@ -1,41 +1,11 @@ // SPDX-License-Identifier: AGPL-3.0-only -//! Core data structures representing the voxel world. +//! Dense and palette-compressed chunk storage forms. -use bytemuck::{Pod, Zeroable}; -use glam::Vec3; +use super::{BlockId, CHUNK_SIZE, CHUNK_VOLUME}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, hash_map::Entry}; -/// The size of a chunk along one axis in blocks. -pub const CHUNK_SIZE: usize = 32; -/// The total number of blocks within a single chunk. -pub const CHUNK_VOLUME: usize = CHUNK_SIZE * CHUNK_SIZE * CHUNK_SIZE; - -/// A unique identifier representing a type of block in the world. -#[repr(transparent)] -#[derive( - Copy, - Clone, - Debug, - Default, - PartialEq, - Eq, - PartialOrd, - Ord, - Hash, - Serialize, - Deserialize, - Pod, - Zeroable, -)] -pub struct BlockId(pub u16); - -impl BlockId { - /// The block identifier representing empty space. - pub const AIR: BlockId = BlockId(0); -} - /// A spatial volume containing voxel data. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Chunk { @@ -179,140 +149,9 @@ impl PalettedChunk { } } -/// The three-dimensional spatial coordinates of a chunk in the world. -#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct ChunkPos { - /// The X coordinate of the chunk. - pub x: i32, - /// The Y coordinate of the chunk. - pub y: i32, - /// The Z coordinate of the chunk. - pub z: i32, -} - -impl ChunkPos { - /// Initializes a new chunk position. - #[must_use] - pub fn new(x: i32, y: i32, z: i32) -> Self { - Self { x, y, z } - } - - /// Initializes a new chunk position from a world-space position measured in blocks. - #[must_use] - #[expect(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] - pub fn from_world(x: f64, y: f64, z: f64) -> Self { - ChunkPos { - x: (x.floor() as i32).div_euclid(CHUNK_SIZE as i32), - y: (y.floor() as i32).div_euclid(CHUNK_SIZE as i32), - z: (z.floor() as i32).div_euclid(CHUNK_SIZE as i32), - } - } -} - -/// A precision-safe position of an entity in the world. -#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] -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::*; - 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() { - // A block at 40 falls in chunk 1 (chunk 1 spans blocks 32..=63). - assert_eq!(ChunkPos::from_world(40.0, 0.0, 0.0).x, 1); - // The last block of chunk 0 (block 31) stays in chunk 0. - assert_eq!(ChunkPos::from_world(31.0, 0.0, 0.0).x, 0); - } - - #[test] - fn from_world_floors_negative_positions() { - // Block -1 belongs to chunk -1, not chunk 0: this is the div_euclid contract. - assert_eq!(ChunkPos::from_world(-1.0, 0.0, 0.0).x, -1); - // Block -33 belongs to chunk -2 (chunk -2 spans blocks -64..=-33). - assert_eq!(ChunkPos::from_world(-33.0, 0.0, 0.0).x, -2); - } - - #[test] - fn from_world_floors_fractional_positions() { - // 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)); - } /// Builds a chunk whose voxels cycle through `distinct` material ids, guaranteeing exactly `distinct` distinct materials and therefore a palette of that size. fn chunk_cycling(distinct: usize) -> Chunk { diff --git a/crates/shared/src/world/coords.rs b/crates/shared/src/world/coords.rs new file mode 100644 index 0000000..afbeb87 --- /dev/null +++ b/crates/shared/src/world/coords.rs @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Chunk-space coordinates. + +use super::CHUNK_SIZE; +use serde::{Deserialize, Serialize}; + +/// The three-dimensional spatial coordinates of a chunk in the world. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ChunkPos { + /// The X coordinate of the chunk. + pub x: i32, + /// The Y coordinate of the chunk. + pub y: i32, + /// The Z coordinate of the chunk. + pub z: i32, +} + +impl ChunkPos { + /// Initializes a new chunk position. + #[must_use] + pub fn new(x: i32, y: i32, z: i32) -> Self { + Self { x, y, z } + } + + /// Initializes a new chunk position from a world-space position measured in blocks. + #[must_use] + #[expect(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] + pub fn from_world(x: f64, y: f64, z: f64) -> Self { + ChunkPos { + x: (x.floor() as i32).div_euclid(CHUNK_SIZE as i32), + y: (y.floor() as i32).div_euclid(CHUNK_SIZE as i32), + z: (z.floor() as i32).div_euclid(CHUNK_SIZE as i32), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_world_maps_positive_positions() { + // A block at 40 falls in chunk 1 (chunk 1 spans blocks 32..=63). + assert_eq!(ChunkPos::from_world(40.0, 0.0, 0.0).x, 1); + // The last block of chunk 0 (block 31) stays in chunk 0. + assert_eq!(ChunkPos::from_world(31.0, 0.0, 0.0).x, 0); + } + + #[test] + fn from_world_floors_negative_positions() { + // Block -1 belongs to chunk -1, not chunk 0: this is the div_euclid contract. + assert_eq!(ChunkPos::from_world(-1.0, 0.0, 0.0).x, -1); + // Block -33 belongs to chunk -2 (chunk -2 spans blocks -64..=-33). + assert_eq!(ChunkPos::from_world(-33.0, 0.0, 0.0).x, -2); + } + + #[test] + fn from_world_floors_fractional_positions() { + // 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); + } +} diff --git a/crates/shared/src/world/entity.rs b/crates/shared/src/world/entity.rs new file mode 100644 index 0000000..5839822 --- /dev/null +++ b/crates/shared/src/world/entity.rs @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Precision-safe entity positions. + +use super::{CHUNK_SIZE, ChunkPos}; +use glam::Vec3; +use serde::{Deserialize, Serialize}; + +/// A precision-safe position of an entity in the world. +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +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::*; + + // `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 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)); + } +} diff --git a/crates/shared/src/world/mod.rs b/crates/shared/src/world/mod.rs new file mode 100644 index 0000000..90db047 --- /dev/null +++ b/crates/shared/src/world/mod.rs @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Core data structures representing the voxel world. +//! +//! The module is split by concept: [`BlockId`] material handles, the [`Chunk`] storage forms, chunk-space coordinates in [`ChunkPos`], and precision-safe entity positions in [`EntityPos`]. Each lives in its own submodule and is re-exported here so callers continue to refer to `shared::world::` regardless of the internal layout. + +mod block; +mod chunk; +mod coords; +mod entity; + +pub use block::BlockId; +pub use chunk::{Chunk, PalettedChunk}; +pub use coords::ChunkPos; +pub use entity::EntityPos; + +/// The size of a chunk along one axis in blocks. +pub const CHUNK_SIZE: usize = 32; +/// The total number of blocks within a single chunk. +pub const CHUNK_VOLUME: usize = CHUNK_SIZE * CHUNK_SIZE * CHUNK_SIZE;