concinnity_core/components/
voxel_world.rs1use crate::ecs::MaterialHandle;
4use crate::ecs::asset_id::AssetId;
5use crate::ecs::de_opt_material_handle;
6use alloc::vec::Vec;
7
8#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
22#[serde(default)]
23pub struct VoxelWorld {
24 pub seed: u64,
27 pub chunk_blocks: [u32; 3],
29 pub block_size: f32,
31 pub view_radius: u32,
33 pub impostor_radius: u32,
38 pub impostor_step: u32,
41 pub load_budget: u32,
43 pub palette: Vec<AssetId>,
46 #[serde(deserialize_with = "de_opt_material_handle")]
48 pub material: Option<MaterialHandle>,
49}
50
51impl Default for VoxelWorld {
52 fn default() -> Self {
53 Self {
54 seed: 0,
55 chunk_blocks: [16, 24, 16],
56 block_size: 1.0,
57 view_radius: 5,
58 impostor_radius: 0,
59 impostor_step: 4,
60 load_budget: 3,
61 palette: Vec::new(),
62 material: None,
63 }
64 }
65}
66
67impl VoxelWorld {
70 pub fn chunk_blocks(&self) -> [u32; 3] {
72 [
73 self.chunk_blocks[0].max(1),
74 self.chunk_blocks[1].max(1),
75 self.chunk_blocks[2].max(1),
76 ]
77 }
78
79 pub fn block_size(&self) -> f32 {
81 self.block_size.max(0.01)
82 }
83
84 pub fn chunk_world_size(&self) -> (f32, f32) {
86 let b = self.chunk_blocks();
87 let s = self.block_size();
88 (b[0] as f32 * s, b[2] as f32 * s)
89 }
90
91 pub fn view_radius(&self) -> i32 {
94 (self.view_radius as i32).clamp(0, 32)
95 }
96
97 pub fn impostor_radius(&self) -> i32 {
101 (self.impostor_radius as i32)
102 .clamp(0, 96)
103 .max(self.view_radius())
104 }
105
106 pub fn impostor_step(&self) -> u32 {
110 self.impostor_step.clamp(1, 64)
111 }
112
113 pub fn impostors_enabled(&self) -> bool {
116 self.impostor_radius() > self.view_radius()
117 }
118
119 pub fn load_budget(&self) -> usize {
122 (self.load_budget as usize).max(1)
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 #[test]
131 fn defaults_stream_a_small_radius_with_impostors_off() {
132 let w = VoxelWorld::default();
133 assert_eq!(w.chunk_blocks(), [16, 24, 16]);
134 assert_eq!(w.block_size(), 1.0);
135 assert_eq!(w.chunk_world_size(), (16.0, 16.0));
136 assert_eq!(w.view_radius(), 5);
137 assert_eq!(w.load_budget(), 3);
138 assert_eq!(w.impostor_step(), 4);
139 assert!(!w.impostors_enabled());
141 assert_eq!(w.impostor_radius(), w.view_radius());
142 assert!(w.palette.is_empty());
143 assert_eq!(w.material, None);
144 }
145
146 #[test]
147 fn a_degenerate_chunk_size_is_floored_to_something_meshable() {
148 let w: VoxelWorld =
151 serde_json::from_str(r#"{"chunk_blocks":[0,0,0],"block_size":0.0}"#).unwrap();
152 assert_eq!(w.chunk_blocks(), [1, 1, 1]);
153 assert_eq!(w.block_size(), 0.01);
154 assert_eq!(w.chunk_world_size(), (0.01, 0.01));
155 }
156
157 #[test]
158 fn chunk_world_size_is_the_horizontal_footprint() {
159 let w: VoxelWorld =
161 serde_json::from_str(r#"{"chunk_blocks":[8,64,4],"block_size":0.5}"#).unwrap();
162 assert_eq!(w.chunk_world_size(), (4.0, 2.0));
163 }
164
165 #[test]
166 fn radii_are_clamped_to_what_the_streamer_can_hold() {
167 let w: VoxelWorld =
168 serde_json::from_str(r#"{"view_radius":999,"impostor_radius":999}"#).unwrap();
169 assert_eq!(w.view_radius(), 32);
170 assert_eq!(w.impostor_radius(), 96);
171 assert!(w.impostors_enabled());
172 }
173
174 #[test]
175 fn an_impostor_radius_never_falls_inside_the_view_radius() {
176 let w: VoxelWorld =
179 serde_json::from_str(r#"{"view_radius":10,"impostor_radius":2}"#).unwrap();
180 assert_eq!(w.impostor_radius(), 10);
181 assert!(!w.impostors_enabled());
182 }
183
184 #[test]
185 fn a_zero_impostor_step_or_load_budget_cannot_wedge_streaming() {
186 let w: VoxelWorld = serde_json::from_str(r#"{"impostor_step":0,"load_budget":0}"#).unwrap();
187 assert_eq!(w.impostor_step(), 1);
188 assert_eq!(w.load_budget(), 1);
189 let w: VoxelWorld = serde_json::from_str(r#"{"impostor_step":999}"#).unwrap();
190 assert_eq!(w.impostor_step(), 64);
191 }
192
193 #[test]
194 fn an_authored_world_round_trips_through_postcard() {
195 crate::test_support::install_resolvers();
196 let w: VoxelWorld = serde_json::from_str(
197 r#"{"seed":42,"palette":["stone","dirt"],"material":"voxel_mat",
198 "view_radius":8,"impostor_radius":24}"#,
199 )
200 .unwrap();
201 assert_eq!(w.palette, [AssetId(5), AssetId(4)]);
202 assert_eq!(w.material, Some(MaterialHandle(9)));
203
204 let bytes = postcard::to_allocvec(&w).unwrap();
205 let back: VoxelWorld = postcard::from_bytes(&bytes).unwrap();
206 assert_eq!(back.seed, 42);
207 assert_eq!(back.palette, [AssetId(5), AssetId(4)]);
208 assert_eq!(back.material, Some(MaterialHandle(9)));
209 assert!(back.impostors_enabled());
210 }
211}