terrain_codec/terrain.rs
1//! One-shot heightmap → quantized-mesh (`.terrain`) encoding.
2//!
3//! This module ties together the three crates that otherwise have to be
4//! wired up by hand:
5//!
6//! 1. [`martini`] generates an adaptive RTIN mesh from the elevation grid.
7//! 2. The mesh's `(u, v, height)` are quantised to the 0..=32767 range.
8//! 3. [`quantized_mesh`] encodes the header, vertices, edge indices and
9//! optional extensions into the quantized-mesh-1.0 byte stream.
10//!
11//! The fiddly bits it handles for you:
12//!
13//! - Re-sampling each mesh vertex's height (martini discards heights once
14//! the error pyramid is built, so the transform has to recover the grid
15//! coordinate from `(u, v)` and read the DEM again).
16//! - Computing the encoded height range from the *mesh* vertices (what is
17//! actually stored), not the full grid.
18//! - Streaming the mesh vertices through
19//! [`QuantizedMeshHeader::from_bounds_with_vertices_iter`] for a tight
20//! horizon-occlusion point.
21//! - Vertex normals via the [`NormalMode`] of your choice.
22//! - Folding the ellipsoid [`curvature_bulge`] into the error pyramid so
23//! nearly-flat tiles keep enough triangles to track the globe at low zoom
24//! (the bulge feeds error estimation only, never the emitted heights).
25//!
26//! # Grid orientation
27//!
28//! `elevations` (and the `get_height` closure's `y`) are **row-major,
29//! north → south**: row `0` is the northern edge, row `grid_size - 1` the
30//! southern edge. This matches [`crate::normals::BufferedElevations`], so a
31//! buffered grid can be reused directly for [`NormalMode::BufferedGradient`].
32//!
33//! # Seamless tiling — the caller supplies the halo
34//!
35//! These functions encode **one tile in isolation**; they never fetch
36//! neighbouring tiles. For gap-free, seam-free output the *caller* must
37//! widen the input to overlap the neighbours — fetch the halo cells along
38//! with the tile and stitch them in before calling:
39//!
40//! - **Geometry seam.** martini needs a `2^n + 1` grid, so an `N`-post DEM
41//! tile needs one extra post on its east and south edges. That `+1` post
42//! is the neighbour tile's *first* post for the shared edge — read it
43//! from the neighbour, don't edge-replicate, or adjacent tiles won't
44//! agree on the boundary and the globe cracks along tile seams.
45//! - **Normal seam.** [`NormalMode::BufferedGradient`] needs a
46//! `buffer`-cell halo of neighbour samples on **every** side (see
47//! [`BufferedElevations`]). Edge vertices read their `±1` neighbours out
48//! of that halo, so the same physical edge gets identical normals from
49//! either tile and lighting stays continuous.
50//!
51//! Gathering that neighbour data (over HTTP, from disk, from a cache, …) is
52//! deliberately left to the caller — hence this module takes an
53//! already-assembled grid rather than fetching tiles itself, which also
54//! keeps it free of any async/runtime assumptions.
55//!
56//! # Example
57//!
58//! ```
59//! use terrain_codec::quantized_mesh::TileBounds;
60//! use terrain_codec::terrain::{encode_terrain, TerrainOptions};
61//!
62//! let grid_size = 65; // 2^6 + 1
63//! let elevations = vec![0.0f32; (grid_size * grid_size) as usize];
64//! let bounds = TileBounds::new(139.0, 35.0, 139.01, 35.01);
65//!
66//! let terrain: Vec<u8> = encode_terrain(
67//! &elevations,
68//! grid_size,
69//! &bounds,
70//! &TerrainOptions {
71//! max_error: 1.0,
72//! ..Default::default()
73//! },
74//! );
75//! assert!(terrain.starts_with(&[0x1f, 0x8b])); // gzip magic (default level 6)
76//! ```
77
78use std::io::{self, Write};
79
80use martini::Martini;
81use quantized_mesh::{
82 EdgeIndices, EncodeOptions, QUANTIZED_MAX, QuantizedMeshEncoder, QuantizedMeshHeader,
83 QuantizedVertices, TileBounds, TileMetadata, WaterMask,
84};
85
86use crate::normals::{BufferedElevations, buffered_gradient_normals, face_normals};
87
88/// How (and whether) per-vertex normals are computed for the oct-encoded
89/// vertex-normals extension.
90#[derive(Debug, Clone, Default)]
91pub enum NormalMode {
92 /// No vertex-normals extension.
93 #[default]
94 None,
95 /// Per-tile face normals ([`crate::normals::face_normals`]). Simple, but
96 /// produces a visible shading seam at tile boundaries.
97 FaceNormals,
98 /// Seam-free DEM-gradient normals
99 /// ([`crate::normals::buffered_gradient_normals`]) sampled from a
100 /// buffer-extended grid. Its `tile_grid_size` must equal the encode
101 /// `grid_size`.
102 ///
103 /// The caller is responsible for filling the `buffer`-cell halo around
104 /// the tile with the **neighbour tiles'** elevations — that overlap is
105 /// what makes edge normals match across the seam. A halo filled by
106 /// edge-replication still encodes fine, but won't be seam-free.
107 BufferedGradient(BufferedElevations),
108}
109
110/// Options controlling [`encode_terrain`] and the other encode functions in
111/// this module.
112#[derive(Debug, Clone)]
113pub struct TerrainOptions {
114 /// Maximum RTIN error threshold in metres. Lower values keep more
115 /// triangles (higher fidelity, larger output).
116 pub max_error: f64,
117 /// Gzip compression level: `0` emits uncompressed bytes, `1..=9` gzip at
118 /// that level. Defaults to `6`.
119 pub compression_level: u32,
120 /// Vertex-normal strategy.
121 pub normals: NormalMode,
122 /// Optional water-mask extension.
123 pub water_mask: Option<WaterMask>,
124 /// Optional metadata (child-tile availability) extension.
125 pub metadata: Option<TileMetadata>,
126}
127
128impl Default for TerrainOptions {
129 fn default() -> Self {
130 Self {
131 max_error: 1.0,
132 compression_level: 6,
133 normals: NormalMode::None,
134 water_mask: None,
135 metadata: None,
136 }
137 }
138}
139
140/// Encode a heightmap to a quantized-mesh `.terrain` byte vector, sampling
141/// elevations through a closure.
142///
143/// `get_height(x, y)` returns the elevation in metres at grid column `x`
144/// (`0..grid_size`, west → east) and row `y` (`0..grid_size`, north →
145/// south).
146///
147/// This is the primitive form; [`encode_terrain`] wraps it for a flat
148/// `&[f32]` grid. `get_height` is called twice per grid vertex that ends up
149/// in the mesh (once while building the error pyramid, once to recover the
150/// stored height), so keep it cheap or memoised.
151///
152/// # Panics
153///
154/// Panics if `grid_size` is not `2^n + 1`, or — for
155/// [`NormalMode::BufferedGradient`] — if the buffered grid's
156/// `tile_grid_size` does not equal `grid_size`.
157pub fn encode_terrain_from_fn<F>(
158 grid_size: u32,
159 bounds: &TileBounds,
160 get_height: F,
161 options: &TerrainOptions,
162) -> Vec<u8>
163where
164 F: Fn(u32, u32) -> f64,
165{
166 let (encoder, encode_opts) = build(grid_size, bounds, get_height, options);
167 encoder.encode_with_options(&encode_opts)
168}
169
170/// Like [`encode_terrain_from_fn`], but streams the encoded bytes to a
171/// writer instead of allocating a `Vec`.
172///
173/// # Panics
174///
175/// Same panics as [`encode_terrain_from_fn`].
176pub fn encode_terrain_from_fn_to<F, W>(
177 grid_size: u32,
178 bounds: &TileBounds,
179 get_height: F,
180 options: &TerrainOptions,
181 writer: W,
182) -> io::Result<()>
183where
184 F: Fn(u32, u32) -> f64,
185 W: Write,
186{
187 let (encoder, encode_opts) = build(grid_size, bounds, get_height, options);
188 encoder.encode_to_with_options(writer, &encode_opts)
189}
190
191/// Encode a flat row-major (north → south) `f32` elevation grid to a
192/// quantized-mesh `.terrain` byte vector.
193///
194/// `elevations.len()` must equal `grid_size * grid_size`.
195///
196/// # Panics
197///
198/// Panics if the length check fails, or for the panics listed on
199/// [`encode_terrain_from_fn`].
200pub fn encode_terrain(
201 elevations: &[f32],
202 grid_size: u32,
203 bounds: &TileBounds,
204 options: &TerrainOptions,
205) -> Vec<u8> {
206 assert_grid_len(elevations.len(), grid_size);
207 let gs = grid_size as usize;
208 encode_terrain_from_fn(
209 grid_size,
210 bounds,
211 |x, y| elevations[y as usize * gs + x as usize] as f64,
212 options,
213 )
214}
215
216/// Like [`encode_terrain`], but streams the encoded bytes to a writer.
217///
218/// # Panics
219///
220/// Same panics as [`encode_terrain`].
221pub fn encode_terrain_to<W: Write>(
222 elevations: &[f32],
223 grid_size: u32,
224 bounds: &TileBounds,
225 options: &TerrainOptions,
226 writer: W,
227) -> io::Result<()> {
228 assert_grid_len(elevations.len(), grid_size);
229 let gs = grid_size as usize;
230 encode_terrain_from_fn_to(
231 grid_size,
232 bounds,
233 |x, y| elevations[y as usize * gs + x as usize] as f64,
234 options,
235 writer,
236 )
237}
238
239fn assert_grid_len(len: usize, grid_size: u32) {
240 let expected = (grid_size as usize) * (grid_size as usize);
241 assert_eq!(
242 len, expected,
243 "elevations length mismatch: expected {expected} ({grid_size}×{grid_size}), got {len}"
244 );
245}
246
247/// Radial deviation (metres) of the WGS84 ellipsoid surface above the flat
248/// bilinear interpolation of the tile's four corners, evaluated at grid cell
249/// `(x, y)`. The encode functions in this module always add this to the height
250/// field that drives martini's error pyramid (never to the stored heights), so
251/// nearly-flat tiles still tessellate enough to track the globe's curvature
252/// instead of collapsing to a flat quad that cuts under the ellipsoid at low
253/// zoom. Exposed so callers can reason about / reproduce the subdivision.
254///
255/// Derivation: along one geodesic edge spanning angle `Δ`, the arc rises above
256/// its chord by `R·(cos((t−½)Δ) − cos(Δ/2))`, which for the small `Δ` of a
257/// tile is `≈ R·(Δ²/2)·t·(1−t)` — zero at the corners, peaking at the centre.
258/// Because martini's interpolation is exact for affine fields, only this
259/// non-linear `t·(1−t)` term contributes error, so it is precisely the signal
260/// that controls subdivision. Longitude and latitude separate; longitude span
261/// is scaled by `cos(lat)` for meridian convergence. The term shrinks with the
262/// square of the tile span, so it forces dense meshes at low zoom (few, large
263/// tiles) and fades to nothing at high zoom (negligible curvature per tile).
264///
265/// `x` / `y` are grid coordinates in `0..grid_size`. The result is zero on the
266/// four tile corners.
267pub fn curvature_bulge(x: u32, y: u32, grid_size: u32, bounds: &TileBounds) -> f64 {
268 // WGS84 mean radius. Sub-metre accuracy here is irrelevant — this only
269 // scales an error threshold, never an emitted height.
270 const EARTH_RADIUS_M: f64 = 6_371_008.8;
271 let n = (grid_size.saturating_sub(1)).max(1) as f64;
272 let u = x as f64 / n;
273 let v = y as f64 / n;
274 let dlon = (bounds.east - bounds.west).to_radians();
275 let dlat = (bounds.north - bounds.south).to_radians();
276 let mid_lat = ((bounds.south + bounds.north) * 0.5).to_radians();
277 let dlon_eff = dlon * mid_lat.cos();
278 0.5 * EARTH_RADIUS_M * (dlat * dlat * v * (1.0 - v) + dlon_eff * dlon_eff * u * (1.0 - u))
279}
280
281/// Run martini, quantise the mesh, build the header + extensions, and return
282/// a ready-to-encode [`QuantizedMeshEncoder`] alongside its [`EncodeOptions`].
283fn build<F>(
284 grid_size: u32,
285 bounds: &TileBounds,
286 get_height: F,
287 options: &TerrainOptions,
288) -> (QuantizedMeshEncoder, EncodeOptions)
289where
290 F: Fn(u32, u32) -> f64,
291{
292 if let NormalMode::BufferedGradient(buf) = &options.normals {
293 assert_eq!(
294 buf.tile_grid_size, grid_size,
295 "BufferedGradient tile_grid_size ({}) must equal encode grid_size ({grid_size})",
296 buf.tile_grid_size
297 );
298 }
299
300 let mut martini = Martini::new(grid_size);
301 let max = (grid_size - 1) as f64;
302 // The error pyramid always sees the ellipsoid bulge so nearly-flat tiles
303 // still subdivide enough to track the globe's curvature; the stored
304 // heights below stay the true `get_height` values (the bulge never leaks
305 // into emitted heights).
306 let tile = martini.create_terrain(|x, y| {
307 get_height(x as u32, y as u32) + curvature_bulge(x as u32, y as u32, grid_size, bounds)
308 });
309
310 // Hijack the UV transform to keep martini's `(u, v)` and re-sample the
311 // height at the grid vertex. Martini computes `u = x/max` and
312 // `v = 1 - y/max`, both exact for grid points, so the inverse recovers
313 // the integer grid coordinate without drift.
314 let (positions, indices, _uvs) =
315 tile.construct_mesh(&mut martini, options.max_error, &mut |(u, v)| {
316 let gx = (u * max).round();
317 let gy = ((1.0 - v) * max).round();
318 (u, v, get_height(gx as u32, gy as u32))
319 });
320
321 let vertex_count = positions.len() / 3;
322
323 // Height range over the mesh vertices — i.e. exactly the heights we
324 // quantise and store. A flat tile collapses to a zero span.
325 let mut min_h = f64::INFINITY;
326 let mut max_h = f64::NEG_INFINITY;
327 for i in 0..vertex_count {
328 let h = positions[i * 3 + 2] as f64;
329 min_h = min_h.min(h);
330 max_h = max_h.max(h);
331 }
332 if vertex_count == 0 {
333 min_h = 0.0;
334 max_h = 0.0;
335 }
336 let height_span = max_h - min_h;
337
338 // Quantise (u, v, height) → 0..=32767.
339 let quant_max = QUANTIZED_MAX as f64;
340 let mut vertices = QuantizedVertices::with_capacity(vertex_count);
341 for i in 0..vertex_count {
342 let u = positions[i * 3] as f64;
343 let v = positions[i * 3 + 1] as f64;
344 let h = positions[i * 3 + 2] as f64;
345 let uq = (u * quant_max).round().clamp(0.0, quant_max) as u16;
346 let vq = (v * quant_max).round().clamp(0.0, quant_max) as u16;
347 let hq = if height_span > 0.0 {
348 (((h - min_h) / height_span) * quant_max)
349 .round()
350 .clamp(0.0, quant_max) as u16
351 } else {
352 0
353 };
354 vertices.push(uq, vq, hq);
355 }
356
357 let edge_indices = EdgeIndices::from_vertices(&vertices);
358
359 // Feed the mesh vertices (geodetic) to the header so the horizon
360 // occlusion point is as tight as possible.
361 let lon_span = bounds.east - bounds.west;
362 let lat_span = bounds.north - bounds.south;
363 let geodetic = (0..vertex_count).map(|i| {
364 let u = positions[i * 3] as f64;
365 let v = positions[i * 3 + 1] as f64;
366 let h = positions[i * 3 + 2] as f64;
367 [bounds.west + u * lon_span, bounds.south + v * lat_span, h]
368 });
369 let header = QuantizedMeshHeader::from_bounds_with_vertices_iter(
370 bounds,
371 min_h as f32,
372 max_h as f32,
373 geodetic,
374 );
375
376 let normals = match &options.normals {
377 NormalMode::None => None,
378 NormalMode::FaceNormals => Some(face_normals(&vertices, &indices, bounds, min_h, max_h)),
379 NormalMode::BufferedGradient(buf) => {
380 Some(buffered_gradient_normals(&vertices, bounds, buf))
381 }
382 };
383
384 let encode_opts = EncodeOptions {
385 include_normals: normals.is_some(),
386 normals,
387 include_water_mask: options.water_mask.is_some(),
388 water_mask: options.water_mask.clone(),
389 include_metadata: options.metadata.is_some(),
390 metadata: options.metadata.clone(),
391 compression_level: options.compression_level,
392 };
393
394 let encoder = QuantizedMeshEncoder::new(header, vertices, indices, edge_indices);
395 (encoder, encode_opts)
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401 use quantized_mesh::DecodedMesh;
402
403 fn bumpy(x: u32, y: u32) -> f64 {
404 ((x as f64) / 8.0).sin() * 50.0 + ((y as f64) / 8.0).cos() * 30.0
405 }
406
407 #[test]
408 fn flat_high_zoom_tile_collapses_to_two_triangles() {
409 // A tiny (high-zoom) flat tile: the curvature bulge is sub-millimetre
410 // here, well under max_error, so it adds no triangles and the tile
411 // collapses to the 2 corner triangles.
412 let bounds = TileBounds::new(139.0, 35.0, 139.01, 35.01);
413 let bytes = encode_terrain_from_fn(
414 65,
415 &bounds,
416 |_, _| 0.0,
417 &TerrainOptions {
418 max_error: 1.0,
419 compression_level: 0,
420 ..Default::default()
421 },
422 );
423
424 let mesh = DecodedMesh::decode(&bytes).expect("decode");
425 assert_eq!(mesh.indices.len(), 6);
426 assert_eq!(mesh.header.min_height, 0.0);
427 assert_eq!(mesh.header.max_height, 0.0);
428 // All four corners present, heights all quantise to 0.
429 assert!(mesh.vertices.height.iter().all(|&h| h == 0));
430 }
431
432 #[test]
433 fn flat_low_zoom_tile_subdivides_for_curvature_keeping_heights_flat() {
434 // A wide (low-zoom), perfectly flat tile. Without the curvature bulge
435 // martini would collapse it to two triangles that cut under the
436 // globe; the bulge forces subdivision — but never touches the emitted
437 // heights, which stay flat at 0.
438 let bounds = TileBounds::new(0.0, 0.0, 90.0, 45.0);
439 let mesh = DecodedMesh::decode(&encode_terrain_from_fn(
440 65,
441 &bounds,
442 |_, _| 0.0,
443 &TerrainOptions {
444 max_error: 1.0,
445 compression_level: 0,
446 ..Default::default()
447 },
448 ))
449 .expect("decode");
450
451 assert!(
452 mesh.indices.len() > 6,
453 "curvature must subdivide a wide flat tile, got {} indices",
454 mesh.indices.len()
455 );
456 // The bulge only feeds the error pyramid — emitted heights stay flat.
457 assert_eq!(mesh.header.min_height, 0.0);
458 assert_eq!(mesh.header.max_height, 0.0);
459 assert!(mesh.vertices.height.iter().all(|&h| h == 0));
460 }
461
462 #[test]
463 fn curvature_bulge_zero_on_corners_and_positive_at_centre() {
464 let b = TileBounds::new(0.0, 0.0, 90.0, 45.0);
465 assert_eq!(curvature_bulge(0, 0, 65, &b), 0.0);
466 assert_eq!(curvature_bulge(64, 0, 65, &b), 0.0);
467 assert_eq!(curvature_bulge(0, 64, 65, &b), 0.0);
468 assert_eq!(curvature_bulge(64, 64, 65, &b), 0.0);
469 assert!(curvature_bulge(32, 32, 65, &b) > 0.0);
470 }
471
472 #[test]
473 fn default_options_gzip_compress() {
474 let bounds = TileBounds::new(139.0, 35.0, 139.01, 35.01);
475 let bytes = encode_terrain_from_fn(65, &bounds, bumpy, &TerrainOptions::default());
476 assert_eq!(&bytes[0..2], &[0x1f, 0x8b]); // gzip magic
477 }
478
479 #[test]
480 fn height_range_matches_decoded_extremes() {
481 let bounds = TileBounds::new(139.0, 35.0, 139.01, 35.01);
482 let bytes = encode_terrain_from_fn(
483 129,
484 &bounds,
485 bumpy,
486 &TerrainOptions {
487 max_error: 0.5,
488 compression_level: 0,
489 ..Default::default()
490 },
491 );
492 let mesh = DecodedMesh::decode(&bytes).expect("decode");
493
494 // The lowest mesh vertex must quantise to 0 and the highest to
495 // QUANTIZED_MAX (the encoded range is defined by the header extremes).
496 assert_eq!(*mesh.vertices.height.iter().min().unwrap(), 0);
497 assert_eq!(*mesh.vertices.height.iter().max().unwrap(), QUANTIZED_MAX);
498 assert!(mesh.header.max_height > mesh.header.min_height);
499 }
500
501 #[test]
502 fn slice_and_closure_agree() {
503 let grid_size = 65u32;
504 let gs = grid_size as usize;
505 let elevations: Vec<f32> = (0..gs * gs)
506 .map(|i| bumpy((i % gs) as u32, (i / gs) as u32) as f32)
507 .collect();
508 let bounds = TileBounds::new(139.0, 35.0, 139.01, 35.01);
509 let opts = TerrainOptions {
510 max_error: 1.0,
511 compression_level: 0,
512 ..Default::default()
513 };
514
515 let from_slice = encode_terrain(&elevations, grid_size, &bounds, &opts);
516 let from_fn = encode_terrain_from_fn(
517 grid_size,
518 &bounds,
519 |x, y| elevations[y as usize * gs + x as usize] as f64,
520 &opts,
521 );
522 assert_eq!(from_slice, from_fn);
523 }
524
525 #[test]
526 fn writer_form_matches_vec_form() {
527 let bounds = TileBounds::new(139.0, 35.0, 139.01, 35.01);
528 let opts = TerrainOptions {
529 max_error: 1.0,
530 compression_level: 6,
531 ..Default::default()
532 };
533 let vec_form = encode_terrain_from_fn(129, &bounds, bumpy, &opts);
534
535 let mut writer_form = Vec::new();
536 encode_terrain_from_fn_to(129, &bounds, bumpy, &opts, &mut writer_form).unwrap();
537 assert_eq!(vec_form, writer_form);
538 }
539
540 #[test]
541 fn face_normals_are_emitted_and_unit_length() {
542 let bounds = TileBounds::new(139.0, 35.0, 139.01, 35.01);
543 let bytes = encode_terrain_from_fn(
544 65,
545 &bounds,
546 bumpy,
547 &TerrainOptions {
548 max_error: 1.0,
549 compression_level: 0,
550 normals: NormalMode::FaceNormals,
551 ..Default::default()
552 },
553 );
554 let mesh = DecodedMesh::decode(&bytes).expect("decode");
555 let normals = mesh.extensions.normals.expect("normals present");
556 assert_eq!(normals.len(), mesh.vertices.len());
557 for n in &normals {
558 let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
559 // Oct-encoding is lossy, so allow a little slack around unit length.
560 assert!(
561 (len - 1.0).abs() < 0.05,
562 "normal not ~unit: {n:?} (len {len})"
563 );
564 }
565 }
566
567 #[test]
568 fn buffered_gradient_normals_are_emitted() {
569 let grid_size = 65u32;
570 let buffer = 1u32;
571 let full = (grid_size + 2 * buffer) as usize;
572 // Buffered grid sampling the same bumpy field, including the halo.
573 let mut buffered = Vec::with_capacity(full * full);
574 for j in 0..full {
575 for i in 0..full {
576 let x = i as i64 - buffer as i64;
577 let y = j as i64 - buffer as i64;
578 buffered.push(bumpy(x.max(0) as u32, y.max(0) as u32));
579 }
580 }
581 let buffered = BufferedElevations::new(buffered, grid_size, buffer);
582
583 let bounds = TileBounds::new(139.0, 35.0, 139.01, 35.01);
584 let bytes = encode_terrain_from_fn(
585 grid_size,
586 &bounds,
587 bumpy,
588 &TerrainOptions {
589 max_error: 1.0,
590 compression_level: 0,
591 normals: NormalMode::BufferedGradient(buffered),
592 ..Default::default()
593 },
594 );
595 let mesh = DecodedMesh::decode(&bytes).expect("decode");
596 let normals = mesh.extensions.normals.expect("normals present");
597 assert_eq!(normals.len(), mesh.vertices.len());
598 }
599
600 #[test]
601 #[should_panic(expected = "elevations length mismatch")]
602 fn slice_length_mismatch_panics() {
603 let bounds = TileBounds::new(139.0, 35.0, 139.01, 35.01);
604 encode_terrain(&[0.0f32; 10], 65, &bounds, &TerrainOptions::default());
605 }
606}