refactor(shared): split world module into per-concept files

This commit is contained in:
Serkyo 2026-07-08 02:37:19 +02:00
parent 3a3ecc13e2
commit b196dc3152
5 changed files with 205 additions and 163 deletions

View file

@ -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);
}

View file

@ -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 {

View file

@ -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);
}
}

View file

@ -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));
}
}

View file

@ -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::<Type>` 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;