refactor(shared): split world module into per-concept files
This commit is contained in:
parent
3a3ecc13e2
commit
b196dc3152
30
crates/shared/src/world/block.rs
Normal file
30
crates/shared/src/world/block.rs
Normal 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);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
63
crates/shared/src/world/coords.rs
Normal file
63
crates/shared/src/world/coords.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
90
crates/shared/src/world/entity.rs
Normal file
90
crates/shared/src/world/entity.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
20
crates/shared/src/world/mod.rs
Normal file
20
crates/shared/src/world/mod.rs
Normal 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;
|
||||
Loading…
Reference in a new issue