1use crate::perlin::PerlinNoise3D;
24use crate::rng::SplitMix64;
25use crate::{CaveParams, MAXZDIM};
26
27const PERLIN_LOWEST_FREQUENCY: f32 = 1.0 / 16.0;
32
33const PERLIN_VOXEL_SCALE: f32 = 8.0;
39
40#[derive(Debug, Clone, Copy)]
42pub struct Seed {
43 pub pos: [f32; 3],
45 pub is_air: bool,
47}
48
49#[must_use]
53#[allow(
54 clippy::cast_possible_truncation,
55 clippy::cast_precision_loss,
56 clippy::cast_sign_loss
57)]
58pub fn place_seeds(params: &CaveParams, vsid: u32) -> Vec<Seed> {
59 let mut rng = SplitMix64::new(params.seed);
60 let total = params.seed_count;
61 let n_air = ((total as f32) * params.air_ratio).round() as usize;
63 let n_air = n_air.min(total);
64 let xy_max = vsid as f32;
65 let z_max = MAXZDIM as f32;
66 let mut seeds = Vec::with_capacity(total);
67 for i in 0..total {
68 let pos_x = rng.next_f32_unit() * xy_max;
69 let pos_y = rng.next_f32_unit() * xy_max;
70 let pos_z = rng.next_f32_unit() * z_max;
71 seeds.push(Seed {
72 pos: [pos_x, pos_y, pos_z],
73 is_air: i < n_air,
74 });
75 }
76 seeds
77}
78
79#[must_use]
85#[allow(clippy::cast_precision_loss)]
86pub fn classify_voxel(seeds: &[Seed], x: u32, y: u32, z: i32, anisotropy: f32) -> bool {
87 let p = [x as f32, y as f32, z as f32];
88 let mut d_air_sq = f32::INFINITY;
89 let mut d_solid_sq = f32::INFINITY;
90 for seed in seeds {
91 let d_sq = anisotropic_dist_sq(p, seed.pos, anisotropy);
92 if seed.is_air {
93 if d_sq < d_air_sq {
94 d_air_sq = d_sq;
95 }
96 } else if d_sq < d_solid_sq {
97 d_solid_sq = d_sq;
98 }
99 }
100 d_air_sq >= d_solid_sq
103}
104
105#[must_use]
118#[allow(clippy::cast_precision_loss, clippy::too_many_arguments)]
119pub fn classify_voxel_with_perlin(
120 seeds: &[Seed],
121 perlin: &PerlinNoise3D,
122 x: u32,
123 y: u32,
124 z: i32,
125 anisotropy: f32,
126 perlin_octaves: u32,
127 perlin_amplitude: f32,
128) -> bool {
129 let p = [x as f32, y as f32, z as f32];
130 let mut d_air_sq = f32::INFINITY;
131 let mut d_solid_sq = f32::INFINITY;
132 for seed in seeds {
133 let d_sq = anisotropic_dist_sq(p, seed.pos, anisotropy);
134 if seed.is_air {
135 if d_sq < d_air_sq {
136 d_air_sq = d_sq;
137 }
138 } else if d_sq < d_solid_sq {
139 d_solid_sq = d_sq;
140 }
141 }
142 if perlin_octaves == 0 || perlin_amplitude == 0.0 {
143 return d_air_sq >= d_solid_sq;
144 }
145 let d_air = d_air_sq.sqrt();
146 let d_solid = d_solid_sq.sqrt();
147 let overlay = perlin.fbm(p[0], p[1], p[2], perlin_octaves, PERLIN_LOWEST_FREQUENCY)
148 * perlin_amplitude
149 * PERLIN_VOXEL_SCALE;
150 (d_air + overlay) >= d_solid
151}
152
153#[must_use]
161#[allow(clippy::cast_sign_loss)]
162pub fn worley_classify_grid(params: &CaveParams, vsid: u32) -> Vec<u8> {
163 let seeds = place_seeds(params, vsid);
164 let perlin_active = params.perlin_octaves > 0 && params.perlin_amplitude > 0.0;
167 let perlin = if perlin_active {
168 Some(PerlinNoise3D::new(
169 params.seed.wrapping_mul(0x9E37_79B9_7F4A_7C15),
170 ))
171 } else {
172 None
173 };
174 let vsid_u = vsid as usize;
175 let maxzdim_u = MAXZDIM as usize;
176 let n_voxels = vsid_u * vsid_u * maxzdim_u;
177 let mut grid = vec![0u8; n_voxels];
178 for y in 0..vsid {
179 for x in 0..vsid {
180 for z in 0..MAXZDIM {
181 let idx = (y as usize * vsid_u + x as usize) * maxzdim_u + z as usize;
182 let solid = if let Some(ref p) = perlin {
183 classify_voxel_with_perlin(
184 &seeds,
185 p,
186 x,
187 y,
188 z,
189 params.anisotropy,
190 params.perlin_octaves,
191 params.perlin_amplitude,
192 )
193 } else {
194 classify_voxel(&seeds, x, y, z, params.anisotropy)
195 };
196 if solid {
197 grid[idx] = 1;
198 }
199 }
200 }
201 }
202 grid
203}
204
205#[inline]
206pub(crate) fn anisotropic_dist_sq(a: [f32; 3], b: [f32; 3], anisotropy: f32) -> f32 {
207 let dx = a[0] - b[0];
208 let dy = a[1] - b[1];
209 let dz = (a[2] - b[2]) * anisotropy;
210 dx * dx + dy * dy + dz * dz
211}
212
213#[cfg(test)]
214#[allow(
215 clippy::cast_possible_truncation,
216 clippy::cast_precision_loss,
217 clippy::cast_sign_loss
218)]
219mod tests {
220 use super::*;
221
222 fn test_params(seed: u64, seed_count: usize, air_ratio: f32) -> CaveParams {
223 CaveParams {
224 seed,
225 seed_count,
226 air_ratio,
227 anisotropy: 1.0,
228 perlin_octaves: 0,
229 perlin_amplitude: 0.0,
230 }
231 }
232
233 #[test]
234 fn place_seeds_deterministic_in_seed() {
235 let p = test_params(42, 16, 0.5);
236 let a = place_seeds(&p, 64);
237 let b = place_seeds(&p, 64);
238 assert_eq!(a.len(), b.len());
239 for (sa, sb) in a.iter().zip(b.iter()) {
240 assert_eq!(sa.pos[0].to_bits(), sb.pos[0].to_bits(), "x");
241 assert_eq!(sa.pos[1].to_bits(), sb.pos[1].to_bits(), "y");
242 assert_eq!(sa.pos[2].to_bits(), sb.pos[2].to_bits(), "z");
243 assert_eq!(sa.is_air, sb.is_air);
244 }
245 }
246
247 #[test]
248 fn place_seeds_different_seed_yields_different_seeds() {
249 let a = place_seeds(&test_params(1, 16, 0.5), 64);
250 let b = place_seeds(&test_params(2, 16, 0.5), 64);
251 let same = a
254 .iter()
255 .zip(b.iter())
256 .filter(|(x, y)| x.pos[0].to_bits() == y.pos[0].to_bits())
257 .count();
258 assert!(same * 4 < a.len(), "too many coincident x positions");
259 }
260
261 #[test]
262 fn place_seeds_air_ratio_split() {
263 let p = test_params(7, 100, 0.4);
264 let seeds = place_seeds(&p, 64);
265 let n_air = seeds.iter().filter(|s| s.is_air).count();
266 assert_eq!(n_air, 40, "40% of 100 seeds tagged air");
267 }
268
269 #[test]
270 fn place_seeds_within_volume_bounds() {
271 let p = test_params(7, 256, 0.5);
272 let seeds = place_seeds(&p, 64);
273 for s in &seeds {
274 assert!((0.0..64.0).contains(&s.pos[0]), "x in bounds");
275 assert!((0.0..64.0).contains(&s.pos[1]), "y in bounds");
276 assert!((0.0..MAXZDIM as f32).contains(&s.pos[2]), "z in bounds");
277 }
278 }
279
280 #[test]
281 fn classify_at_air_seed_returns_air() {
282 let seeds = vec![
285 Seed {
286 pos: [10.0, 20.0, 30.0],
287 is_air: true,
288 },
289 Seed {
290 pos: [100.0, 100.0, 100.0],
291 is_air: false,
292 },
293 ];
294 assert!(!classify_voxel(&seeds, 10, 20, 30, 1.0), "should be air");
295 }
296
297 #[test]
298 fn classify_at_solid_seed_returns_solid() {
299 let seeds = vec![
300 Seed {
301 pos: [100.0, 100.0, 100.0],
302 is_air: true,
303 },
304 Seed {
305 pos: [10.0, 20.0, 30.0],
306 is_air: false,
307 },
308 ];
309 assert!(classify_voxel(&seeds, 10, 20, 30, 1.0), "should be solid");
310 }
311
312 #[test]
313 fn classify_anisotropy_squishes_caves_vertically() {
314 let seeds = vec![
320 Seed {
321 pos: [10.0, 20.0, 30.0],
322 is_air: true,
323 },
324 Seed {
325 pos: [10.0, 20.0, 50.0],
326 is_air: false,
327 },
328 ];
329 assert!(
331 !classify_voxel(&seeds, 10, 20, 39, 1.0),
332 "isotropic — closer to air seed"
333 );
334 assert!(
336 !classify_voxel(&seeds, 10, 20, 39, 2.0),
337 "aniso=2 — closer to air seed"
338 );
339 }
340
341 #[test]
342 #[allow(clippy::naive_bytecount)]
343 fn worley_classify_grid_air_ratio_roughly_matches() {
344 let p = test_params(7, 32, 0.5);
347 let vsid = 16u32;
348 let grid = worley_classify_grid(&p, vsid);
349 let n_air = grid.iter().filter(|&&b| b == 0).count();
350 let total = grid.len();
351 let ratio = n_air as f32 / total as f32;
352 assert!(
353 (0.30..=0.70).contains(&ratio),
354 "expected ~50% air, got {:.2}",
355 ratio * 100.0
356 );
357 }
358
359 #[test]
360 fn worley_classify_grid_deterministic_in_seed() {
361 let p = test_params(1234, 32, 0.5);
362 let g1 = worley_classify_grid(&p, 16);
363 let g2 = worley_classify_grid(&p, 16);
364 assert_eq!(g1, g2, "same seed → byte-stable grid");
365 }
366
367 #[test]
368 fn perlin_overlay_perturbs_classification() {
369 let no_perlin = test_params(99, 32, 0.5);
372 let with_perlin = CaveParams {
373 perlin_octaves: 3,
374 perlin_amplitude: 0.4, ..no_perlin
376 };
377 let g1 = worley_classify_grid(&no_perlin, 16);
378 let g2 = worley_classify_grid(&with_perlin, 16);
379 let diffs = g1.iter().zip(g2.iter()).filter(|(a, b)| a != b).count();
380 assert!(
381 diffs > 0,
382 "Perlin overlay should perturb the air/solid boundary"
383 );
384 let total = g1.len();
387 assert!(
388 diffs * 100 / total < 30,
389 "Perlin shifts boundary, doesn't randomise: {diffs} of {total} flipped"
390 );
391 }
392
393 #[test]
394 fn perlin_overlay_byte_stable() {
395 let p = CaveParams {
397 seed: 7,
398 seed_count: 32,
399 air_ratio: 0.5,
400 anisotropy: 1.0,
401 perlin_octaves: 3,
402 perlin_amplitude: 0.15,
403 };
404 let g1 = worley_classify_grid(&p, 16);
405 let g2 = worley_classify_grid(&p, 16);
406 assert_eq!(g1, g2);
407 }
408
409 #[test]
410 fn perlin_disabled_when_amplitude_zero() {
411 let no_perlin = test_params(11, 32, 0.5);
413 let zero_amplitude = CaveParams {
414 perlin_octaves: 3,
415 perlin_amplitude: 0.0,
416 ..no_perlin
417 };
418 let g1 = worley_classify_grid(&no_perlin, 16);
419 let g2 = worley_classify_grid(&zero_amplitude, 16);
420 assert_eq!(g1, g2, "amplitude=0 should disable overlay");
421 }
422}