1pub mod heightmap;
35pub mod biome;
36pub mod biomes;
37pub mod chunks;
38pub mod vegetation;
39pub mod streaming;
40pub mod mod_types;
41
42pub use mod_types::{ChunkCoord, ChunkState, TerrainConfig, TerrainChunk};
43pub use heightmap::{
44 HeightMap, DiamondSquare, FractalNoise, VoronoiPlates, PerlinTerrain,
45 HydraulicErosion, ThermalErosion, WindErosion,
46};
47pub use biome::{
48 BiomeType, BiomeParams, BiomeClassifier, BiomeMap, ClimateMap,
49 ClimateSimulator, VegetationDensity, BiomeColor, TransitionZone, SeasonFactor,
50};
51pub use vegetation::{
52 VegetationSystem, VegetationInstance, VegetationKind, VegetationLod,
53 TreeType, TreeParams, TreeSkeleton, TreeSegment, GrassCluster, GrassField,
54 RockPlacement, RockCluster, VegetationPainter, ImpostorBillboard,
55 generate_impostors,
56};
57pub use streaming::{
58 StreamingManager, ChunkCache, LoadQueue, ChunkGenerator, ChunkSerializer,
59 StreamingStats, VisibilitySet, LodScheduler, Prefetcher,
60};
61
62use glam::Vec3;
63
64#[derive(Clone, Debug)]
68pub struct TerrainMaterial {
69 pub albedo: Vec3,
70 pub normal: Vec3,
71 pub roughness: f32,
72 pub layers: Vec<TerrainLayer>,
73}
74
75impl Default for TerrainMaterial {
76 fn default() -> Self {
77 Self {
78 albedo: Vec3::new(0.5, 0.45, 0.3),
79 normal: Vec3::new(0.5, 1.0, 0.5),
80 roughness: 0.85,
81 layers: Vec::new(),
82 }
83 }
84}
85
86impl TerrainMaterial {
87 pub fn new() -> Self { Self::default() }
88
89 pub fn add_layer(&mut self, layer: TerrainLayer) {
91 self.layers.push(layer);
92 }
93
94 pub fn sample_albedo(&self, altitude: f32, slope: f32) -> Vec3 {
96 if self.layers.is_empty() { return self.albedo; }
97 let mut result = Vec3::ZERO;
98 let mut total_weight = 0.0f32;
99 for layer in &self.layers {
100 let alt_blend = smooth_step(layer.blend_start, layer.blend_end, altitude);
101 let slope_ok = slope >= layer.slope_min && slope <= layer.slope_max;
102 let weight = alt_blend * if slope_ok { 1.0 } else { 0.0 };
103 result += layer.albedo * weight;
104 total_weight += weight;
105 }
106 if total_weight < 1e-6 { self.albedo } else { result / total_weight }
107 }
108}
109
110fn smooth_step(edge0: f32, edge1: f32, x: f32) -> f32 {
111 let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
112 t * t * (3.0 - 2.0 * t)
113}
114
115#[derive(Clone, Debug)]
119pub struct TerrainLayer {
120 pub name: String,
121 pub albedo: Vec3,
122 pub texture_scale: f32,
123 pub blend_start: f32,
124 pub blend_end: f32,
125 pub slope_min: f32,
126 pub slope_max: f32,
127 pub roughness: f32,
128}
129
130impl TerrainLayer {
131 pub fn grass() -> Self {
132 Self {
133 name: "Grass".to_string(),
134 albedo: Vec3::new(0.3, 0.55, 0.15),
135 texture_scale: 4.0,
136 blend_start: 0.05,
137 blend_end: 0.5,
138 slope_min: 0.0,
139 slope_max: 0.4,
140 roughness: 0.9,
141 }
142 }
143
144 pub fn rock() -> Self {
145 Self {
146 name: "Rock".to_string(),
147 albedo: Vec3::new(0.5, 0.47, 0.44),
148 texture_scale: 2.0,
149 blend_start: 0.0,
150 blend_end: 1.0,
151 slope_min: 0.35,
152 slope_max: 1.0,
153 roughness: 0.75,
154 }
155 }
156
157 pub fn snow() -> Self {
158 Self {
159 name: "Snow".to_string(),
160 albedo: Vec3::new(0.9, 0.92, 0.95),
161 texture_scale: 3.0,
162 blend_start: 0.75,
163 blend_end: 0.9,
164 slope_min: 0.0,
165 slope_max: 0.6,
166 roughness: 0.3,
167 }
168 }
169
170 pub fn sand() -> Self {
171 Self {
172 name: "Sand".to_string(),
173 albedo: Vec3::new(0.85, 0.78, 0.55),
174 texture_scale: 5.0,
175 blend_start: 0.05,
176 blend_end: 0.15,
177 slope_min: 0.0,
178 slope_max: 0.2,
179 roughness: 0.95,
180 }
181 }
182}
183
184pub struct TerrainCollider<'a> {
188 heightmap: &'a HeightMap,
189 chunk_size: f32,
190 height_scale: f32,
191}
192
193impl<'a> TerrainCollider<'a> {
194 pub fn new(heightmap: &'a HeightMap, chunk_size: f32, height_scale: f32) -> Self {
195 Self { heightmap, chunk_size, height_scale }
196 }
197
198 pub fn height_at(&self, x: f32, z: f32) -> f32 {
199 let lx = (x / self.chunk_size * self.heightmap.width as f32)
200 .clamp(0.0, self.heightmap.width as f32 - 1.0);
201 let lz = (z / self.chunk_size * self.heightmap.height as f32)
202 .clamp(0.0, self.heightmap.height as f32 - 1.0);
203 self.heightmap.sample_bilinear(lx, lz) * self.height_scale
204 }
205
206 pub fn normal_at(&self, x: f32, z: f32) -> Vec3 {
207 let lx = (x / self.chunk_size * self.heightmap.width as f32) as usize;
208 let lz = (z / self.chunk_size * self.heightmap.height as f32) as usize;
209 self.heightmap.normal_at(
210 lx.min(self.heightmap.width - 1),
211 lz.min(self.heightmap.height - 1),
212 )
213 }
214
215 pub fn is_below_surface(&self, x: f32, y: f32, z: f32) -> bool {
216 y < self.height_at(x, z)
217 }
218
219 pub fn ray_cast(&self, origin: Vec3, direction: Vec3, max_dist: f32) -> Option<f32> {
221 let dir = direction.normalize();
222 let step = self.chunk_size / self.heightmap.width as f32;
223 let mut t = 0.0f32;
224 let mut above = !self.is_below_surface(origin.x, origin.y, origin.z);
225 while t < max_dist {
226 let p = origin + dir * t;
227 let h = self.height_at(p.x, p.z);
228 let now_above = p.y > h;
229 if above && !now_above {
230 let mut lo = t - step;
231 let mut hi = t;
232 for _ in 0..8 {
233 let mid = (lo + hi) * 0.5;
234 let pm = origin + dir * mid;
235 if pm.y > self.height_at(pm.x, pm.z) { lo = mid; } else { hi = mid; }
236 }
237 return Some((lo + hi) * 0.5);
238 }
239 above = now_above;
240 t += step;
241 }
242 None
243 }
244
245 pub fn aabb_intersects(&self, center: Vec3, half_extents: Vec3) -> bool {
247 let corners = [
248 (center.x - half_extents.x, center.z - half_extents.z),
249 (center.x + half_extents.x, center.z - half_extents.z),
250 (center.x - half_extents.x, center.z + half_extents.z),
251 (center.x + half_extents.x, center.z + half_extents.z),
252 (center.x, center.z),
253 ];
254 let min_y = center.y - half_extents.y;
255 corners.iter().any(|&(x, z)| self.height_at(x, z) >= min_y)
256 }
257}
258
259pub struct TerrainQuery<'a> {
263 chunks: &'a mut StreamingManager,
264 config: TerrainConfig,
265}
266
267impl<'a> TerrainQuery<'a> {
268 pub fn new(chunks: &'a mut StreamingManager, config: TerrainConfig) -> Self {
269 Self { chunks, config }
270 }
271
272 pub fn sample_height(&mut self, x: f32, z: f32) -> f32 {
273 self.chunks.sample_height_world(x, z)
274 }
275
276 pub fn sample_normal(&mut self, x: f32, z: f32) -> Vec3 {
277 let chunk_world = self.config.chunk_size as f32;
278 let cx = (x / chunk_world).floor() as i32;
279 let cz = (z / chunk_world).floor() as i32;
280 let coord = ChunkCoord(cx, cz);
281 let lx = (x - cx as f32 * chunk_world) / chunk_world * self.config.chunk_size as f32;
282 let lz = (z - cz as f32 * chunk_world) / chunk_world * self.config.chunk_size as f32;
283 if let Some(chunk) = self.chunks.get_chunk(coord) {
284 let xi = (lx as usize).min(chunk.heightmap.width - 1);
285 let zi = (lz as usize).min(chunk.heightmap.height - 1);
286 chunk.heightmap.normal_at(xi, zi)
287 } else {
288 Vec3::Y
289 }
290 }
291
292 pub fn get_biome(&mut self, x: f32, z: f32) -> BiomeType {
293 let chunk_world = self.config.chunk_size as f32;
294 let cx = (x / chunk_world).floor() as i32;
295 let cz = (z / chunk_world).floor() as i32;
296 let coord = ChunkCoord(cx, cz);
297 let lx = ((x - cx as f32 * chunk_world) / chunk_world * self.config.chunk_size as f32) as usize;
298 let lz = ((z - cz as f32 * chunk_world) / chunk_world * self.config.chunk_size as f32) as usize;
299 if let Some(chunk) = self.chunks.get_chunk(coord) {
300 if let Some(ref bm) = chunk.biome_map {
301 return bm.get(lx.min(bm.width - 1), lz.min(bm.height - 1));
302 }
303 }
304 BiomeType::Grassland
305 }
306
307 pub fn is_underwater(&mut self, x: f32, z: f32) -> bool {
308 self.chunks.sample_height_world(x, z) < 0.1
309 }
310}
311
312pub struct TerrainManager {
316 pub config: TerrainConfig,
317 pub streaming: StreamingManager,
318 pub material: TerrainMaterial,
319 camera_pos: Vec3,
320 current_month: u32,
321}
322
323impl TerrainManager {
324 pub fn new(config: TerrainConfig) -> Self {
325 let streaming = StreamingManager::new(config.clone());
326 Self {
327 streaming,
328 material: Self::default_material(),
329 config,
330 camera_pos: Vec3::ZERO,
331 current_month: 0,
332 }
333 }
334
335 pub fn new_synchronous(config: TerrainConfig) -> Self {
336 let streaming = StreamingManager::new_synchronous(config.clone());
337 Self {
338 streaming,
339 material: Self::default_material(),
340 config,
341 camera_pos: Vec3::ZERO,
342 current_month: 0,
343 }
344 }
345
346 fn default_material() -> TerrainMaterial {
347 let mut mat = TerrainMaterial::new();
348 mat.add_layer(TerrainLayer::sand());
349 mat.add_layer(TerrainLayer::grass());
350 mat.add_layer(TerrainLayer::rock());
351 mat.add_layer(TerrainLayer::snow());
352 mat
353 }
354
355 pub fn update(&mut self, camera_pos: Vec3) {
356 self.camera_pos = camera_pos;
357 self.streaming.update(camera_pos);
358 }
359
360 pub fn set_month(&mut self, month: u32) {
361 self.current_month = month % 12;
362 }
363
364 pub fn sample_height(&mut self, x: f32, z: f32) -> f32 {
365 self.streaming.sample_height_world(x, z)
366 }
367
368 pub fn sample_normal(&mut self, x: f32, z: f32) -> Vec3 {
369 let chunk_world = self.config.chunk_size as f32;
370 let cx = (x / chunk_world).floor() as i32;
371 let cz = (z / chunk_world).floor() as i32;
372 let coord = ChunkCoord(cx, cz);
373 let lx = ((x - cx as f32 * chunk_world) / chunk_world * self.config.chunk_size as f32) as usize;
374 let lz = ((z - cz as f32 * chunk_world) / chunk_world * self.config.chunk_size as f32) as usize;
375 if let Some(chunk) = self.streaming.get_chunk(coord) {
376 let xi = lx.min(chunk.heightmap.width - 1);
377 let zi = lz.min(chunk.heightmap.height - 1);
378 chunk.heightmap.normal_at(xi, zi)
379 } else {
380 Vec3::Y
381 }
382 }
383
384 pub fn get_biome(&mut self, x: f32, z: f32) -> BiomeType {
385 let chunk_world = self.config.chunk_size as f32;
386 let cx = (x / chunk_world).floor() as i32;
387 let cz = (z / chunk_world).floor() as i32;
388 let coord = ChunkCoord(cx, cz);
389 let lx = ((x - cx as f32 * chunk_world) / chunk_world * self.config.chunk_size as f32) as usize;
390 let lz = ((z - cz as f32 * chunk_world) / chunk_world * self.config.chunk_size as f32) as usize;
391 if let Some(chunk) = self.streaming.get_chunk(coord) {
392 if let Some(ref bm) = chunk.biome_map {
393 return bm.get(lx.min(bm.width - 1), lz.min(bm.height - 1));
394 }
395 }
396 BiomeType::Grassland
397 }
398
399 pub fn is_underwater(&mut self, x: f32, z: f32) -> bool {
400 self.sample_height(x, z) < 0.1
401 }
402
403 pub fn stats(&self) -> &StreamingStats { self.streaming.stats() }
404 pub fn loaded_chunk_count(&self) -> usize { self.streaming.cache_size() }
405
406 pub fn ensure_loaded(&mut self, x: f32, z: f32) {
407 let chunk_world = self.config.chunk_size as f32;
408 let coord = ChunkCoord(
409 (x / chunk_world).floor() as i32,
410 (z / chunk_world).floor() as i32,
411 );
412 self.streaming.force_load(coord);
413 }
414
415 pub fn collider_for_chunk<'a>(chunk: &'a TerrainChunk, config: &TerrainConfig) -> TerrainCollider<'a> {
416 TerrainCollider::new(&chunk.heightmap, config.chunk_size as f32, 100.0)
417 }
418}
419
420#[cfg(test)]
423mod tests {
424 use super::*;
425
426 fn simple_config() -> TerrainConfig {
427 TerrainConfig { chunk_size: 16, view_distance: 1, lod_levels: 2, seed: 42 }
428 }
429
430 #[test]
431 fn test_chunk_coord_neighbors() {
432 let c = ChunkCoord(0, 0);
433 let n4 = c.neighbors_4();
434 assert!(n4.contains(&ChunkCoord(-1, 0)));
435 assert!(n4.contains(&ChunkCoord(1, 0)));
436 assert!(n4.contains(&ChunkCoord(0, -1)));
437 assert!(n4.contains(&ChunkCoord(0, 1)));
438 }
439
440 #[test]
441 fn test_chunk_coord_chebyshev() {
442 assert_eq!(ChunkCoord(0, 0).chebyshev_distance(ChunkCoord(3, 2)), 3);
443 assert_eq!(ChunkCoord(0, 0).chebyshev_distance(ChunkCoord(0, 0)), 0);
444 }
445
446 #[test]
447 fn test_chunk_coord_euclidean() {
448 let d = ChunkCoord(0, 0).euclidean_distance(ChunkCoord(3, 4));
449 assert!((d - 5.0).abs() < 1e-4);
450 }
451
452 #[test]
453 fn test_chunk_coord_world_pos() {
454 let c = ChunkCoord(2, 3);
455 let p = c.to_world_pos(64.0);
456 assert!((p.x - 160.0).abs() < 1e-4);
457 assert!((p.z - 224.0).abs() < 1e-4);
458 }
459
460 #[test]
461 fn test_chunk_coord_from_world_pos() {
462 let p = Vec3::new(130.0, 0.0, 200.0);
463 let c = ChunkCoord::from_world_pos(p, 64.0);
464 assert_eq!(c, ChunkCoord(2, 3));
465 }
466
467 #[test]
468 fn test_chunk_coord_within_radius() {
469 assert!( ChunkCoord(0, 0).within_radius(ChunkCoord(2, 2), 3));
470 assert!(!ChunkCoord(0, 0).within_radius(ChunkCoord(5, 0), 3));
471 }
472
473 #[test]
474 fn test_terrain_material_sample_albedo() {
475 let mut mat = TerrainMaterial::new();
476 mat.add_layer(TerrainLayer::grass());
477 mat.add_layer(TerrainLayer::snow());
478 let snow_color = mat.sample_albedo(0.9, 0.1);
479 let grass_color = mat.sample_albedo(0.2, 0.1);
480 assert!(snow_color.x > grass_color.x || snow_color.y > grass_color.y);
481 }
482
483 #[test]
484 fn test_terrain_collider_height_at() {
485 let mut hm = HeightMap::new(64, 64);
486 for i in 0..(64 * 64) { hm.data[i] = 0.5; }
487 let col = TerrainCollider::new(&hm, 64.0, 100.0);
488 let h = col.height_at(32.0, 32.0);
489 assert!((h - 50.0).abs() < 1.0);
490 }
491
492 #[test]
493 fn test_terrain_collider_is_below() {
494 let mut hm = HeightMap::new(64, 64);
495 for i in 0..(64 * 64) { hm.data[i] = 0.5; }
496 let col = TerrainCollider::new(&hm, 64.0, 100.0);
497 assert!( col.is_below_surface(32.0, 10.0, 32.0));
498 assert!(!col.is_below_surface(32.0, 80.0, 32.0));
499 }
500
501 #[test]
502 fn test_terrain_collider_ray_cast() {
503 let mut hm = HeightMap::new(64, 64);
504 for i in 0..(64 * 64) { hm.data[i] = 0.5; }
505 let col = TerrainCollider::new(&hm, 64.0, 100.0);
506 let hit = col.ray_cast(
507 Vec3::new(32.0, 200.0, 32.0),
508 Vec3::new(0.0, -1.0, 0.0),
509 300.0,
510 );
511 assert!(hit.is_some(), "Ray should hit flat terrain");
512 let dist = hit.unwrap();
513 assert!((dist - 150.0).abs() < 5.0);
514 }
515
516 #[test]
517 fn test_terrain_collider_aabb() {
518 let mut hm = HeightMap::new(64, 64);
519 for i in 0..(64 * 64) { hm.data[i] = 0.5; }
520 let col = TerrainCollider::new(&hm, 64.0, 100.0);
521 assert!( col.aabb_intersects(Vec3::new(32.0, 50.0, 32.0), Vec3::new(5.0, 5.0, 5.0)));
522 assert!(!col.aabb_intersects(Vec3::new(32.0, 500.0, 32.0), Vec3::new(5.0, 5.0, 5.0)));
523 }
524
525 #[test]
526 fn test_terrain_manager_creation() {
527 let config = simple_config();
528 let manager = TerrainManager::new_synchronous(config);
529 assert_eq!(manager.loaded_chunk_count(), 0);
530 }
531
532 #[test]
533 fn test_terrain_manager_update() {
534 let config = simple_config();
535 let mut manager = TerrainManager::new_synchronous(config);
536 manager.update(Vec3::new(0.0, 50.0, 0.0));
537 let _s = manager.stats();
538 }
539
540 #[test]
541 fn test_terrain_manager_ensure_loaded() {
542 let config = simple_config();
543 let mut manager = TerrainManager::new_synchronous(config);
544 manager.ensure_loaded(0.0, 0.0);
545 assert_eq!(manager.loaded_chunk_count(), 1);
546 }
547
548 #[test]
549 fn test_terrain_layers_valid() {
550 for layer in &[TerrainLayer::sand(), TerrainLayer::grass(), TerrainLayer::rock(), TerrainLayer::snow()] {
551 assert!(layer.albedo.x >= 0.0 && layer.albedo.x <= 1.0);
552 assert!(layer.albedo.y >= 0.0 && layer.albedo.y <= 1.0);
553 assert!(layer.albedo.z >= 0.0 && layer.albedo.z <= 1.0);
554 assert!(layer.blend_start <= layer.blend_end);
555 }
556 }
557}
558
559#[derive(Clone, Debug)]
563pub struct TerrainPainter {
564 pub brush_radius: f32,
565 pub brush_strength: f32,
566 pub brush_falloff: BrushFalloff,
567 pub mode: PaintMode,
568}
569
570#[derive(Clone, Copy, Debug)]
572pub enum BrushFalloff {
573 Linear,
574 Smooth,
575 Constant,
576 Gaussian,
577}
578
579#[derive(Clone, Copy, Debug)]
581pub enum PaintMode {
582 Raise,
583 Lower,
584 Flatten { target: f32 },
585 Smooth,
586 Noise { seed: u64, scale: f32 },
587}
588
589impl TerrainPainter {
590 pub fn new(radius: f32, strength: f32) -> Self {
591 Self {
592 brush_radius: radius,
593 brush_strength: strength,
594 brush_falloff: BrushFalloff::Smooth,
595 mode: PaintMode::Raise,
596 }
597 }
598
599 fn falloff(&self, dist_normalized: f32) -> f32 {
600 match self.brush_falloff {
601 BrushFalloff::Linear => (1.0 - dist_normalized).max(0.0),
602 BrushFalloff::Smooth => {
603 let t = (1.0 - dist_normalized).clamp(0.0, 1.0);
604 t * t * (3.0 - 2.0 * t)
605 }
606 BrushFalloff::Constant => if dist_normalized < 1.0 { 1.0 } else { 0.0 },
607 BrushFalloff::Gaussian => {
608 let sigma = 0.4f32;
609 (-(dist_normalized * dist_normalized) / (2.0 * sigma * sigma)).exp()
610 }
611 }
612 }
613
614 pub fn apply(&self, hm: &mut HeightMap, cx: f32, cz: f32) {
616 let r = self.brush_radius;
617 let x0 = ((cx - r).floor() as i32).max(0) as usize;
618 let z0 = ((cz - r).floor() as i32).max(0) as usize;
619 let x1 = ((cx + r).ceil() as i32).min(hm.width as i32 - 1) as usize;
620 let z1 = ((cz + r).ceil() as i32).min(hm.height as i32 - 1) as usize;
621
622 for z in z0..=z1 {
623 for x in x0..=x1 {
624 let dx = x as f32 - cx;
625 let dz = z as f32 - cz;
626 let dist = (dx * dx + dz * dz).sqrt();
627 if dist >= r { continue; }
628 let falloff = self.falloff(dist / r);
629 let delta = self.brush_strength * falloff;
630 let cur = hm.get(x, z);
631 let new_val = match self.mode {
632 PaintMode::Raise => cur + delta,
633 PaintMode::Lower => cur - delta,
634 PaintMode::Flatten { target } => cur + (target - cur) * delta,
635 PaintMode::Smooth => {
636 let n = hm.normal_at(x, z);
637 let neighbors = [
639 hm.get(x.saturating_sub(1), z),
640 hm.get((x+1).min(hm.width-1), z),
641 hm.get(x, z.saturating_sub(1)),
642 hm.get(x, (z+1).min(hm.height-1)),
643 ];
644 let avg = neighbors.iter().sum::<f32>() / 4.0;
645 cur + (avg - cur) * delta
646 }
647 PaintMode::Noise { seed, scale } => {
648 let noise = heightmap::GradientNoisePublic::new(seed);
649 let n = noise.noise2d(x as f32 * scale, z as f32 * scale);
650 cur + (n * 2.0 - 1.0) * delta
651 }
652 };
653 hm.set(x, z, new_val.clamp(0.0, 1.0));
654 }
655 }
656 }
657
658 pub fn preview_samples(&self, cx: f32, cz: f32, sample_count: usize) -> Vec<(f32, f32, f32)> {
660 let mut samples = Vec::new();
661 let r = self.brush_radius;
662 for i in 0..sample_count {
663 let angle = i as f32 * std::f32::consts::TAU / sample_count as f32;
664 for dist_step in 0..=4 {
665 let dist = r * dist_step as f32 / 4.0;
666 let x = cx + angle.cos() * dist;
667 let z = cz + angle.sin() * dist;
668 let intensity = self.falloff(dist / r.max(0.001));
669 samples.push((x, z, intensity));
670 }
671 }
672 samples
673 }
674}
675
676pub struct TerrainHeightmapBuilder {
680 width: usize,
681 height: usize,
682 steps: Vec<BuildStep>,
683}
684
685enum BuildStep {
686 Diamond { roughness: f32, seed: u64 },
687 Fractal { octaves: usize, lacunarity: f32, persistence: f32, scale: f32, seed: u64 },
688 Voronoi { num_plates: usize, seed: u64 },
689 Perlin { octaves: usize, scale: f32, seed: u64 },
690 Erode { kind: ErosionKind, iterations: usize },
691 Terrace { levels: usize },
692 IslandMask{ falloff: f32 },
693 Normalize,
694 Blur { radius: usize },
695 Sharpen { amount: f32 },
696}
697
698enum ErosionKind {
699 Hydraulic { rain: f32, capacity: f32, evaporation: f32, seed: u64 },
700 Thermal { talus: f32 },
701 Wind { dir: glam::Vec2 },
702}
703
704impl TerrainHeightmapBuilder {
705 pub fn new(width: usize, height: usize) -> Self {
706 Self { width, height, steps: Vec::new() }
707 }
708
709 pub fn diamond_square(mut self, roughness: f32, seed: u64) -> Self {
710 self.steps.push(BuildStep::Diamond { roughness, seed });
711 self
712 }
713
714 pub fn fractal_noise(mut self, octaves: usize, lacunarity: f32, persistence: f32, scale: f32, seed: u64) -> Self {
715 self.steps.push(BuildStep::Fractal { octaves, lacunarity, persistence, scale, seed });
716 self
717 }
718
719 pub fn voronoi_plates(mut self, num_plates: usize, seed: u64) -> Self {
720 self.steps.push(BuildStep::Voronoi { num_plates, seed });
721 self
722 }
723
724 pub fn perlin(mut self, octaves: usize, scale: f32, seed: u64) -> Self {
725 self.steps.push(BuildStep::Perlin { octaves, scale, seed });
726 self
727 }
728
729 pub fn hydraulic_erosion(mut self, iterations: usize, rain: f32, capacity: f32, evap: f32, seed: u64) -> Self {
730 self.steps.push(BuildStep::Erode { kind: ErosionKind::Hydraulic { rain, capacity, evaporation: evap, seed }, iterations });
731 self
732 }
733
734 pub fn thermal_erosion(mut self, iterations: usize, talus: f32) -> Self {
735 self.steps.push(BuildStep::Erode { kind: ErosionKind::Thermal { talus }, iterations });
736 self
737 }
738
739 pub fn wind_erosion(mut self, iterations: usize, dir: glam::Vec2) -> Self {
740 self.steps.push(BuildStep::Erode { kind: ErosionKind::Wind { dir }, iterations });
741 self
742 }
743
744 pub fn terrace(mut self, levels: usize) -> Self {
745 self.steps.push(BuildStep::Terrace { levels });
746 self
747 }
748
749 pub fn island_mask(mut self, falloff: f32) -> Self {
750 self.steps.push(BuildStep::IslandMask { falloff });
751 self
752 }
753
754 pub fn normalize(mut self) -> Self {
755 self.steps.push(BuildStep::Normalize);
756 self
757 }
758
759 pub fn blur(mut self, radius: usize) -> Self {
760 self.steps.push(BuildStep::Blur { radius });
761 self
762 }
763
764 pub fn sharpen(mut self, amount: f32) -> Self {
765 self.steps.push(BuildStep::Sharpen { amount });
766 self
767 }
768
769 pub fn build(self) -> HeightMap {
771 let mut hm: Option<HeightMap> = None;
772 let w = self.width;
773 let h = self.height;
774
775 for step in self.steps {
776 match step {
777 BuildStep::Diamond { roughness, seed } => {
778 let size = w.max(h).next_power_of_two();
779 let generated = DiamondSquare::generate(size, roughness, seed);
780 let resampled = generated.resample(w, h);
781 hm = Some(Self::merge(hm, resampled));
782 }
783 BuildStep::Fractal { octaves, lacunarity, persistence, scale, seed } => {
784 let generated = FractalNoise::generate(w, h, octaves, lacunarity, persistence, scale, seed);
785 hm = Some(Self::merge(hm, generated));
786 }
787 BuildStep::Voronoi { num_plates, seed } => {
788 let generated = VoronoiPlates::generate(w, h, num_plates, seed);
789 hm = Some(Self::merge(hm, generated));
790 }
791 BuildStep::Perlin { octaves, scale, seed } => {
792 let generated = PerlinTerrain::generate(w, h, octaves, scale, seed);
793 hm = Some(Self::merge(hm, generated));
794 }
795 BuildStep::Erode { kind, iterations } => {
796 if let Some(ref mut m) = hm {
797 match kind {
798 ErosionKind::Hydraulic { rain, capacity, evaporation, seed } => {
799 HydraulicErosion::erode(m, iterations, rain, capacity, evaporation, seed);
800 }
801 ErosionKind::Thermal { talus } => {
802 ThermalErosion::erode(m, iterations, talus);
803 }
804 ErosionKind::Wind { dir } => {
805 WindErosion::erode(m, dir, iterations);
806 }
807 }
808 }
809 }
810 BuildStep::Terrace { levels } => {
811 if let Some(ref mut m) = hm { m.terrace(levels); }
812 }
813 BuildStep::IslandMask { falloff } => {
814 if let Some(ref mut m) = hm { m.island_mask(falloff); }
815 }
816 BuildStep::Normalize => {
817 if let Some(ref mut m) = hm { m.normalize(); }
818 }
819 BuildStep::Blur { radius } => {
820 if let Some(ref mut m) = hm { m.blur(radius); }
821 }
822 BuildStep::Sharpen { amount } => {
823 if let Some(ref mut m) = hm { m.sharpen(amount); }
824 }
825 }
826 }
827
828 hm.unwrap_or_else(|| HeightMap::new(w, h))
829 }
830
831 fn merge(existing: Option<HeightMap>, new: HeightMap) -> HeightMap {
833 match existing {
834 None => new,
835 Some(mut e) => {
836 for (a, &b) in e.data.iter_mut().zip(new.data.iter()) {
837 *a = (*a + b) * 0.5;
838 }
839 e
840 }
841 }
842 }
843}
844
845#[derive(Clone, Debug)]
851pub struct TerrainLodParams {
852 pub num_levels: usize,
854 pub thresholds: Vec<f32>,
856 pub divisors: Vec<usize>,
858}
859
860impl TerrainLodParams {
861 pub fn new(num_levels: usize, base_threshold: f32, chunk_size: f32) -> Self {
862 let thresholds: Vec<f32> = (0..num_levels)
863 .map(|l| base_threshold * (1 << l) as f32)
864 .collect();
865 let divisors: Vec<usize> = (0..num_levels)
866 .map(|l| 1 << l)
867 .collect();
868 Self { num_levels, thresholds, divisors }
869 }
870
871 pub fn lod_for_distance(&self, dist: f32) -> usize {
873 for (l, &thresh) in self.thresholds.iter().enumerate() {
874 if dist < thresh { return l; }
875 }
876 self.num_levels - 1
877 }
878
879 pub fn resolution(&self, lod: usize, base_size: usize) -> usize {
881 let div = self.divisors.get(lod).copied().unwrap_or(1 << (self.num_levels - 1));
882 (base_size / div).max(4)
883 }
884}
885
886#[derive(Clone, Debug)]
890pub struct TerrainMetadata {
891 pub seed: u64,
892 pub world_name: String,
893 pub generation_time: f32,
894 pub total_chunks: usize,
895 pub sea_level: f32,
896 pub max_height: f32,
897 pub land_fraction: f32,
898 pub biome_counts: [usize; 20],
899}
900
901impl TerrainMetadata {
902 pub fn new(seed: u64, world_name: &str) -> Self {
903 Self {
904 seed,
905 world_name: world_name.to_string(),
906 generation_time: 0.0,
907 total_chunks: 0,
908 sea_level: 0.1,
909 max_height: 100.0,
910 land_fraction: 0.0,
911 biome_counts: [0usize; 20],
912 }
913 }
914
915 pub fn compute_land_fraction(hm: &HeightMap, sea_level: f32) -> f32 {
917 let land = hm.data.iter().filter(|&&v| v > sea_level).count();
918 land as f32 / hm.data.len() as f32
919 }
920}
921
922pub struct TerrainRaycastSystem;
926
927impl TerrainRaycastSystem {
928 pub fn batch_raycast(
930 hm: &HeightMap,
931 chunk_size: f32,
932 height_scale: f32,
933 rays: &[(Vec3, Vec3)], max_dist: f32,
935 ) -> Vec<Option<(f32, Vec3)>> {
936 let collider = TerrainCollider::new(hm, chunk_size, height_scale);
937 rays.iter().map(|&(origin, dir)| {
938 collider.ray_cast(origin, dir, max_dist)
939 .map(|d| (d, origin + dir.normalize() * d))
940 }).collect()
941 }
942
943 pub fn first_hit(
945 hm: &HeightMap,
946 chunk_size: f32,
947 height_scale: f32,
948 rays: &[(Vec3, Vec3)],
949 max_dist: f32,
950 ) -> Option<(usize, f32, Vec3)> {
951 let collider = TerrainCollider::new(hm, chunk_size, height_scale);
952 for (i, &(origin, dir)) in rays.iter().enumerate() {
953 if let Some(d) = collider.ray_cast(origin, dir, max_dist) {
954 return Some((i, d, origin + dir.normalize() * d));
955 }
956 }
957 None
958 }
959}
960
961#[derive(Clone, Debug)]
965pub struct TerrainWater {
966 pub sea_level: f32, pub river_width: f32,
968 pub water_mask: HeightMap,
970}
971
972impl TerrainWater {
973 pub fn new(heightmap: &HeightMap, sea_level: f32) -> Self {
974 let mut water_mask = HeightMap::new(heightmap.width, heightmap.height);
975 for (i, &h) in heightmap.data.iter().enumerate() {
976 water_mask.data[i] = if h <= sea_level { 1.0 } else { 0.0 };
977 }
978 Self { sea_level, river_width: 2.0, water_mask }
979 }
980
981 pub fn is_underwater(&self, x: f32, z: f32) -> bool {
983 let lx = x.clamp(0.0, (self.water_mask.width - 1) as f32);
984 let lz = z.clamp(0.0, (self.water_mask.height - 1) as f32);
985 self.water_mask.sample_bilinear(lx, lz) > 0.5
986 }
987
988 pub fn water_coverage(&self) -> f32 {
990 self.water_mask.data.iter().filter(|&&v| v > 0.5).count() as f32
991 / self.water_mask.data.len() as f32
992 }
993
994 pub fn depth(&self, height: f32) -> f32 {
996 (self.sea_level - height).max(0.0)
997 }
998}
999
1000#[cfg(test)]
1003mod extended_mod_tests {
1004 use super::*;
1005
1006 #[test]
1007 fn test_terrain_painter_raise() {
1008 let mut hm = HeightMap::new(64, 64);
1009 let painter = TerrainPainter {
1010 brush_radius: 10.0,
1011 brush_strength: 0.5,
1012 brush_falloff: BrushFalloff::Smooth,
1013 mode: PaintMode::Raise,
1014 };
1015 painter.apply(&mut hm, 32.0, 32.0);
1016 assert!(hm.get(32, 32) > 0.0);
1017 assert_eq!(hm.get(0, 0), 0.0);
1018 }
1019
1020 #[test]
1021 fn test_terrain_painter_lower() {
1022 let mut hm = HeightMap::new(64, 64);
1023 for v in hm.data.iter_mut() { *v = 0.5; }
1024 let painter = TerrainPainter {
1025 brush_radius: 10.0,
1026 brush_strength: 0.3,
1027 brush_falloff: BrushFalloff::Linear,
1028 mode: PaintMode::Lower,
1029 };
1030 painter.apply(&mut hm, 32.0, 32.0);
1031 assert!(hm.get(32, 32) < 0.5);
1032 }
1033
1034 #[test]
1035 fn test_terrain_painter_flatten() {
1036 let mut hm = HeightMap::new(64, 64);
1037 for v in hm.data.iter_mut() { *v = 0.8; }
1038 let painter = TerrainPainter {
1039 brush_radius: 20.0,
1040 brush_strength: 1.0,
1041 brush_falloff: BrushFalloff::Constant,
1042 mode: PaintMode::Flatten { target: 0.4 },
1043 };
1044 painter.apply(&mut hm, 32.0, 32.0);
1045 assert!((hm.get(32, 32) - 0.4).abs() < 0.05);
1047 }
1048
1049 #[test]
1050 fn test_terrain_heightmap_builder() {
1051 let hm = TerrainHeightmapBuilder::new(32, 32)
1052 .fractal_noise(4, 2.0, 0.5, 3.0, 42)
1053 .normalize()
1054 .blur(1)
1055 .build();
1056 assert_eq!(hm.width, 32);
1057 assert_eq!(hm.height, 32);
1058 let mn = hm.min_value();
1059 let mx = hm.max_value();
1060 assert!(mn >= 0.0 && mx <= 1.0);
1061 }
1062
1063 #[test]
1064 fn test_terrain_heightmap_builder_multi_step() {
1065 let hm = TerrainHeightmapBuilder::new(32, 32)
1066 .fractal_noise(4, 2.0, 0.5, 3.0, 42)
1067 .island_mask(2.0)
1068 .normalize()
1069 .terrace(4)
1070 .build();
1071 assert_eq!(hm.data.len(), 32 * 32);
1072 }
1073
1074 #[test]
1075 fn test_terrain_lod_params() {
1076 let lod = TerrainLodParams::new(4, 50.0, 64.0);
1077 assert_eq!(lod.lod_for_distance(10.0), 0);
1078 assert_eq!(lod.lod_for_distance(60.0), 1);
1079 assert_eq!(lod.lod_for_distance(120.0), 2);
1080 assert_eq!(lod.resolution(0, 64), 64);
1081 assert_eq!(lod.resolution(1, 64), 32);
1082 assert_eq!(lod.resolution(2, 64), 16);
1083 }
1084
1085 #[test]
1086 fn test_terrain_water() {
1087 let hm = heightmap::FractalNoise::generate(32, 32, 4, 2.0, 0.5, 3.0, 42);
1088 let water = TerrainWater::new(&hm, 0.15);
1089 let coverage = water.water_coverage();
1090 assert!(coverage >= 0.0 && coverage <= 1.0);
1091 }
1092
1093 #[test]
1094 fn test_terrain_metadata() {
1095 let hm = heightmap::FractalNoise::generate(32, 32, 4, 2.0, 0.5, 3.0, 42);
1096 let frac = TerrainMetadata::compute_land_fraction(&hm, 0.15);
1097 assert!(frac >= 0.0 && frac <= 1.0);
1098 }
1099
1100 #[test]
1101 fn test_terrain_raycast_system_batch() {
1102 let mut hm = HeightMap::new(64, 64);
1103 for v in hm.data.iter_mut() { *v = 0.5; }
1104 let rays = vec![
1105 (Vec3::new(32.0, 200.0, 32.0), Vec3::new(0.0, -1.0, 0.0)),
1106 (Vec3::new(10.0, 200.0, 10.0), Vec3::new(0.0, -1.0, 0.0)),
1107 ];
1108 let results = TerrainRaycastSystem::batch_raycast(&hm, 64.0, 100.0, &rays, 300.0);
1109 assert_eq!(results.len(), 2);
1110 assert!(results[0].is_some());
1111 }
1112
1113 #[test]
1114 fn test_smooth_step() {
1115 assert!((smooth_step(0.0, 1.0, 0.0) - 0.0).abs() < 1e-5);
1116 assert!((smooth_step(0.0, 1.0, 1.0) - 1.0).abs() < 1e-5);
1117 assert!((smooth_step(0.0, 1.0, 0.5) - 0.5).abs() < 1e-5);
1118 }
1119}
1120
1121#[derive(Debug, Clone)]
1127pub enum TerrainEvent {
1128 ChunkLoaded { coord: mod_types::ChunkCoord },
1129 ChunkUnloaded { coord: mod_types::ChunkCoord },
1130 HeightmapModified { coord: mod_types::ChunkCoord, affected_cells: u32 },
1131 BiomeTransitionDetected { from: biome::BiomeType, to: biome::BiomeType },
1132 ErosionCycleCompleted { chunk: mod_types::ChunkCoord, delta_energy: f32 },
1133 WaterLevelChanged { old: f32, new: f32 },
1134 LodChanged { coord: mod_types::ChunkCoord, old_lod: u8, new_lod: u8 },
1135}
1136
1137#[derive(Debug, Default)]
1139pub struct TerrainEventQueue {
1140 events: std::collections::VecDeque<TerrainEvent>,
1141 pub max_capacity: usize,
1142}
1143
1144impl TerrainEventQueue {
1145 pub fn new(capacity: usize) -> Self {
1146 Self { events: std::collections::VecDeque::new(), max_capacity: capacity }
1147 }
1148
1149 pub fn push(&mut self, event: TerrainEvent) {
1150 if self.events.len() >= self.max_capacity {
1151 self.events.pop_front(); }
1153 self.events.push_back(event);
1154 }
1155
1156 pub fn pop(&mut self) -> Option<TerrainEvent> {
1157 self.events.pop_front()
1158 }
1159
1160 pub fn drain_all(&mut self) -> Vec<TerrainEvent> {
1161 self.events.drain(..).collect()
1162 }
1163
1164 pub fn len(&self) -> usize { self.events.len() }
1165 pub fn is_empty(&self) -> bool { self.events.is_empty() }
1166}
1167
1168#[derive(Debug, Clone)]
1174pub struct TerrainSnapshot {
1175 pub width: usize,
1176 pub height: usize,
1177 data: Vec<f32>,
1178 pub timestamp: u64,
1179}
1180
1181impl TerrainSnapshot {
1182 pub fn capture(hm: &heightmap::HeightMap, timestamp: u64) -> Self {
1183 Self { width: hm.width, height: hm.height, data: hm.data.clone(), timestamp }
1184 }
1185
1186 pub fn restore_to(&self, hm: &mut heightmap::HeightMap) {
1187 if hm.width == self.width && hm.height == self.height {
1188 hm.data.copy_from_slice(&self.data);
1189 }
1190 }
1191
1192 pub fn byte_size(&self) -> usize {
1193 self.data.len() * 4
1194 }
1195}
1196
1197#[derive(Debug, Clone)]
1199pub struct TerrainDiff {
1200 pub width: usize,
1201 pub height: usize,
1202 pub changes: Vec<(u32, f32, f32)>,
1204}
1205
1206impl TerrainDiff {
1207 pub fn compute(before: &TerrainSnapshot, after: &TerrainSnapshot) -> Self {
1208 assert_eq!(before.data.len(), after.data.len());
1209 let changes = before.data.iter().zip(after.data.iter()).enumerate()
1210 .filter_map(|(i, (&old, &new))| {
1211 if (old - new).abs() > 1e-7 { Some((i as u32, old, new)) } else { None }
1212 })
1213 .collect();
1214 Self { width: before.width, height: before.height, changes }
1215 }
1216
1217 pub fn apply(&self, hm: &mut heightmap::HeightMap) {
1218 for &(idx, _old, new) in &self.changes {
1219 hm.data[idx as usize] = new;
1220 }
1221 }
1222
1223 pub fn revert(&self, hm: &mut heightmap::HeightMap) {
1224 for &(idx, old, _new) in &self.changes {
1225 hm.data[idx as usize] = old;
1226 }
1227 }
1228
1229 pub fn changed_cell_count(&self) -> usize {
1230 self.changes.len()
1231 }
1232}
1233
1234pub struct TerrainUndoStack {
1236 undo: Vec<TerrainDiff>,
1237 redo: Vec<TerrainDiff>,
1238 pub max_depth: usize,
1239}
1240
1241impl TerrainUndoStack {
1242 pub fn new(max_depth: usize) -> Self {
1243 Self { undo: Vec::new(), redo: Vec::new(), max_depth }
1244 }
1245
1246 pub fn push(&mut self, diff: TerrainDiff) {
1247 if self.undo.len() >= self.max_depth {
1248 self.undo.remove(0);
1249 }
1250 self.undo.push(diff);
1251 self.redo.clear();
1252 }
1253
1254 pub fn undo(&mut self, hm: &mut heightmap::HeightMap) -> bool {
1255 if let Some(diff) = self.undo.pop() {
1256 diff.revert(hm);
1257 self.redo.push(diff);
1258 true
1259 } else { false }
1260 }
1261
1262 pub fn redo(&mut self, hm: &mut heightmap::HeightMap) -> bool {
1263 if let Some(diff) = self.redo.pop() {
1264 diff.apply(hm);
1265 self.undo.push(diff);
1266 true
1267 } else { false }
1268 }
1269
1270 pub fn can_undo(&self) -> bool { !self.undo.is_empty() }
1271 pub fn can_redo(&self) -> bool { !self.redo.is_empty() }
1272}
1273
1274#[derive(Debug, Default, Clone)]
1280pub struct TerrainWorldStats {
1281 pub total_chunks: u32,
1282 pub loaded_chunks: u32,
1283 pub visible_chunks: u32,
1284 pub total_triangles: u64,
1285 pub vegetation_instances: u64,
1286 pub memory_bytes: u64,
1287 pub last_update_ms: f64,
1288}
1289
1290impl TerrainWorldStats {
1291 pub fn memory_mb(&self) -> f64 {
1292 self.memory_bytes as f64 / (1024.0 * 1024.0)
1293 }
1294
1295 pub fn load_ratio(&self) -> f32 {
1296 if self.total_chunks == 0 { 0.0 } else {
1297 self.loaded_chunks as f32 / self.total_chunks as f32
1298 }
1299 }
1300
1301 pub fn describe(&self) -> String {
1302 format!(
1303 "Chunks: {}/{} loaded ({} visible), Tris: {}, Veg: {}, Mem: {:.1} MB",
1304 self.loaded_chunks, self.total_chunks, self.visible_chunks,
1305 self.total_triangles, self.vegetation_instances,
1306 self.memory_mb(),
1307 )
1308 }
1309}
1310
1311pub struct TerrainPresets;
1317
1318impl TerrainPresets {
1319 pub fn flat_plains() -> mod_types::TerrainConfig {
1320 mod_types::TerrainConfig {
1321 chunk_size: 64,
1322 lod_levels: 3,
1323 view_distance: 8,
1324 seed: 1,
1325 }
1326 }
1327
1328 pub fn mountainous() -> mod_types::TerrainConfig {
1329 mod_types::TerrainConfig {
1330 chunk_size: 128,
1331 lod_levels: 5,
1332 view_distance: 12,
1333 seed: 42,
1334 }
1335 }
1336
1337 pub fn ocean_archipelago() -> mod_types::TerrainConfig {
1338 mod_types::TerrainConfig {
1339 chunk_size: 128,
1340 lod_levels: 4,
1341 view_distance: 16,
1342 seed: 777,
1343 }
1344 }
1345
1346 pub fn desert_dunes() -> mod_types::TerrainConfig {
1347 mod_types::TerrainConfig {
1348 chunk_size: 64,
1349 lod_levels: 3,
1350 view_distance: 10,
1351 seed: 314,
1352 }
1353 }
1354}
1355
1356#[cfg(test)]
1361mod extended_terrain_tests {
1362 use super::*;
1363
1364 #[test]
1365 fn test_terrain_event_queue_capacity() {
1366 let mut q = TerrainEventQueue::new(3);
1367 for i in 0..5 {
1368 q.push(TerrainEvent::WaterLevelChanged { old: i as f32, new: i as f32 + 1.0 });
1369 }
1370 assert_eq!(q.len(), 3);
1372 }
1373
1374 #[test]
1375 fn test_terrain_event_queue_drain() {
1376 let mut q = TerrainEventQueue::new(10);
1377 q.push(TerrainEvent::WaterLevelChanged { old: 0.0, new: 1.0 });
1378 q.push(TerrainEvent::WaterLevelChanged { old: 1.0, new: 2.0 });
1379 let events = q.drain_all();
1380 assert_eq!(events.len(), 2);
1381 assert!(q.is_empty());
1382 }
1383
1384 #[test]
1385 fn test_terrain_snapshot_restore() {
1386 let mut hm = HeightMap::new(16, 16);
1387 for v in hm.data.iter_mut() { *v = 0.7; }
1388 let snap = TerrainSnapshot::capture(&hm, 1000);
1389 for v in hm.data.iter_mut() { *v = 0.2; }
1390 snap.restore_to(&mut hm);
1391 assert!((hm.data[0] - 0.7).abs() < 1e-5);
1392 }
1393
1394 #[test]
1395 fn test_terrain_diff_apply_revert() {
1396 let mut hm = HeightMap::new(16, 16);
1397 for v in hm.data.iter_mut() { *v = 0.5; }
1398 let before = TerrainSnapshot::capture(&hm, 0);
1399 hm.data[10] = 0.9;
1400 let after = TerrainSnapshot::capture(&hm, 1);
1401 let diff = TerrainDiff::compute(&before, &after);
1402 assert_eq!(diff.changed_cell_count(), 1);
1403 diff.revert(&mut hm);
1404 assert!((hm.data[10] - 0.5).abs() < 1e-5);
1405 diff.apply(&mut hm);
1406 assert!((hm.data[10] - 0.9).abs() < 1e-5);
1407 }
1408
1409 #[test]
1410 fn test_undo_stack() {
1411 let mut hm = HeightMap::new(16, 16);
1412 for v in hm.data.iter_mut() { *v = 0.5; }
1413 let before = TerrainSnapshot::capture(&hm, 0);
1414 hm.data[5] = 0.8;
1415 let after = TerrainSnapshot::capture(&hm, 1);
1416 let diff = TerrainDiff::compute(&before, &after);
1417 let mut stack = TerrainUndoStack::new(10);
1418 stack.push(diff);
1419 assert!(stack.can_undo());
1420 assert!(!stack.can_redo());
1421 assert!(stack.undo(&mut hm));
1422 assert!((hm.data[5] - 0.5).abs() < 1e-5);
1423 assert!(stack.can_redo());
1424 assert!(stack.redo(&mut hm));
1425 assert!((hm.data[5] - 0.8).abs() < 1e-5);
1426 }
1427
1428 #[test]
1429 fn test_terrain_world_stats() {
1430 let stats = TerrainWorldStats {
1431 total_chunks: 100,
1432 loaded_chunks: 40,
1433 visible_chunks: 25,
1434 total_triangles: 500_000,
1435 vegetation_instances: 12_000,
1436 memory_bytes: 64 * 1024 * 1024,
1437 last_update_ms: 16.7,
1438 };
1439 assert!((stats.memory_mb() - 64.0).abs() < 0.01);
1440 assert!((stats.load_ratio() - 0.4).abs() < 1e-4);
1441 let desc = stats.describe();
1442 assert!(desc.contains("40/100"));
1443 }
1444
1445 #[test]
1446 fn test_terrain_presets() {
1447 let plains = TerrainPresets::flat_plains();
1448 assert_eq!(plains.chunk_size, 64);
1449 let mountains = TerrainPresets::mountainous();
1450 assert!(mountains.lod_levels >= 5);
1451 let desert = TerrainPresets::desert_dunes();
1452 assert_eq!(desert.seed, 314);
1453 }
1454}