Skip to main content

galeon_engine_terrain/
lib.rs

1// SPDX-License-Identifier: AGPL-3.0-only OR Commercial
2
3use std::error::Error;
4use std::fmt;
5use std::io::{BufRead, Seek};
6use std::sync::Arc;
7
8use galeon_engine::{
9    Engine, MaterialHandle, MeshHandle, ObjectType, Plugin, Transform, Visibility,
10};
11
12/// Heightfield sampled over the X/Z plane.
13///
14/// Heights are stored row-major from north-to-south in the heightmap's local
15/// Z direction. `origin` is the world-space `[x, z]` coordinate of sample
16/// `(0, 0)`, and `size` spans from sample `(0, 0)` to `(width - 1, height - 1)`.
17#[derive(Debug, Clone, PartialEq)]
18pub struct Terrain {
19    origin: [f32; 2],
20    size: [f32; 2],
21    sample_count: [u32; 2],
22    pixel_stride: [f32; 2],
23    heights: Arc<[f32]>,
24    min_height: f32,
25    max_height: f32,
26}
27
28impl Terrain {
29    /// Construct a terrain from row-major height samples.
30    ///
31    /// `width` and `height` are sample counts, not quad counts. Both must be at
32    /// least `2`, and `heights.len()` must equal `width * height`.
33    pub fn new(
34        origin: [f32; 2],
35        size: [f32; 2],
36        width: u32,
37        height: u32,
38        heights: Vec<f32>,
39    ) -> Result<Self, TerrainError> {
40        if width < 2 || height < 2 {
41            return Err(TerrainError::InvalidDimensions { width, height });
42        }
43        let expected = width as usize * height as usize;
44        if heights.len() != expected {
45            return Err(TerrainError::HeightCount {
46                expected,
47                actual: heights.len(),
48            });
49        }
50        if !size[0].is_finite() || !size[1].is_finite() || size[0] <= 0.0 || size[1] <= 0.0 {
51            return Err(TerrainError::InvalidSize { size });
52        }
53        if !origin[0].is_finite() || !origin[1].is_finite() {
54            return Err(TerrainError::InvalidOrigin { origin });
55        }
56        if heights.iter().any(|h| !h.is_finite()) {
57            return Err(TerrainError::NonFiniteHeight);
58        }
59
60        let (min_height, max_height) = min_max(&heights);
61        Ok(Self {
62            origin,
63            size,
64            sample_count: [width, height],
65            pixel_stride: [size[0] / (width - 1) as f32, size[1] / (height - 1) as f32],
66            heights: heights.into(),
67            min_height,
68            max_height,
69        })
70    }
71
72    /// Load a 16-bit grayscale PNG heightmap.
73    pub fn from_png16_reader<R: BufRead + Seek>(
74        reader: R,
75        options: Png16HeightmapOptions,
76    ) -> Result<Self, TerrainError> {
77        if !options.height_min.is_finite()
78            || !options.height_max.is_finite()
79            || !options.vertical_exaggeration.is_finite()
80        {
81            return Err(TerrainError::InvalidHeightScale);
82        }
83        if options.height_max < options.height_min {
84            return Err(TerrainError::InvalidHeightRange {
85                min: options.height_min,
86                max: options.height_max,
87            });
88        }
89
90        let decoder = png::Decoder::new(reader);
91        let mut png_reader = decoder.read_info().map_err(TerrainError::PngDecode)?;
92        let info = png_reader.info();
93        if info.color_type != png::ColorType::Grayscale || info.bit_depth != png::BitDepth::Sixteen
94        {
95            return Err(TerrainError::UnsupportedPng {
96                color_type: info.color_type,
97                bit_depth: info.bit_depth,
98            });
99        }
100
101        let buffer_size = png_reader
102            .output_buffer_size()
103            .ok_or(TerrainError::UnknownPngBufferSize)?;
104        let mut bytes = vec![0; buffer_size];
105        let frame = png_reader
106            .next_frame(&mut bytes)
107            .map_err(TerrainError::PngDecode)?;
108        let data = &bytes[..frame.buffer_size()];
109        if data.len() % 2 != 0 {
110            return Err(TerrainError::MalformedPngData);
111        }
112
113        let height_range = options.height_max - options.height_min;
114        let mut heights = Vec::with_capacity(data.len() / 2);
115        for px in data.chunks_exact(2) {
116            let raw = u16::from_be_bytes([px[0], px[1]]);
117            let normalized = raw as f32 / u16::MAX as f32;
118            heights.push(
119                options.height_min + normalized * height_range * options.vertical_exaggeration,
120            );
121        }
122
123        Self::new(
124            options.origin,
125            options.size,
126            frame.width,
127            frame.height,
128            heights,
129        )
130    }
131
132    /// World-space `[x, z]` origin of the first sample.
133    pub fn origin(&self) -> [f32; 2] {
134        self.origin
135    }
136
137    /// World-space `[x, z]` span covered by the terrain.
138    pub fn size(&self) -> [f32; 2] {
139        self.size
140    }
141
142    /// `[width, height]` sample counts.
143    pub fn sample_count(&self) -> [u32; 2] {
144        self.sample_count
145    }
146
147    /// Distance between adjacent samples in world units.
148    pub fn pixel_stride(&self) -> [f32; 2] {
149        self.pixel_stride
150    }
151
152    /// Raw row-major height samples.
153    pub fn heights(&self) -> &[f32] {
154        &self.heights
155    }
156
157    /// Minimum loaded height.
158    pub fn min_height(&self) -> f32 {
159        self.min_height
160    }
161
162    /// Maximum loaded height.
163    pub fn max_height(&self) -> f32 {
164        self.max_height
165    }
166
167    /// Axis-aligned bounds as `[min_x, min_y, min_z]` and `[max_x, max_y, max_z]`.
168    pub fn bounds(&self) -> ([f32; 3], [f32; 3]) {
169        (
170            [self.origin[0], self.min_height, self.origin[1]],
171            [
172                self.origin[0] + self.size[0],
173                self.max_height,
174                self.origin[1] + self.size[1],
175            ],
176        )
177    }
178
179    /// Bilinearly sample height at world-space `(x, z)`.
180    ///
181    /// Returns `None` outside the terrain bounds.
182    pub fn height_at(&self, x: f32, z: f32) -> Option<f32> {
183        let [u, v] = self.world_to_grid(x, z)?;
184        Some(self.sample_bilinear(u, v))
185    }
186
187    /// Estimate the surface normal at world-space `(x, z)`.
188    ///
189    /// Uses central differences over the source heightfield and clamps neighbor
190    /// taps at the terrain edge.
191    pub fn normal_at(&self, x: f32, z: f32) -> Option<[f32; 3]> {
192        let [u, v] = self.world_to_grid(x, z)?;
193        let max_u = (self.sample_count[0] - 1) as f32;
194        let max_v = (self.sample_count[1] - 1) as f32;
195        let left_u = (u - 1.0).max(0.0);
196        let right_u = (u + 1.0).min(max_u);
197        let down_v = (v - 1.0).max(0.0);
198        let up_v = (v + 1.0).min(max_v);
199
200        let left = self.sample_bilinear(left_u, v);
201        let right = self.sample_bilinear(right_u, v);
202        let down = self.sample_bilinear(u, down_v);
203        let up = self.sample_bilinear(u, up_v);
204
205        let dx = (right_u - left_u) * self.pixel_stride[0];
206        let dz = (up_v - down_v) * self.pixel_stride[1];
207        let dhdx = (right - left) / dx;
208        let dhdz = (up - down) / dz;
209        normalize([-dhdx, 1.0, -dhdz])
210    }
211
212    fn world_to_grid(&self, x: f32, z: f32) -> Option<[f32; 2]> {
213        if !x.is_finite() || !z.is_finite() {
214            return None;
215        }
216        let max_x = self.origin[0] + self.size[0];
217        let max_z = self.origin[1] + self.size[1];
218        if x < self.origin[0] || x > max_x || z < self.origin[1] || z > max_z {
219            return None;
220        }
221        Some([
222            (x - self.origin[0]) / self.pixel_stride[0],
223            (z - self.origin[1]) / self.pixel_stride[1],
224        ])
225    }
226
227    fn sample_bilinear(&self, u: f32, v: f32) -> f32 {
228        let max_x = (self.sample_count[0] - 1) as f32;
229        let max_z = (self.sample_count[1] - 1) as f32;
230        let u = u.clamp(0.0, max_x);
231        let v = v.clamp(0.0, max_z);
232
233        let x0 = u.floor() as u32;
234        let z0 = v.floor() as u32;
235        let x1 = (x0 + 1).min(self.sample_count[0] - 1);
236        let z1 = (z0 + 1).min(self.sample_count[1] - 1);
237        let tx = u - x0 as f32;
238        let tz = v - z0 as f32;
239
240        let h00 = self.sample_at(x0, z0);
241        let h10 = self.sample_at(x1, z0);
242        let h01 = self.sample_at(x0, z1);
243        let h11 = self.sample_at(x1, z1);
244        let a = h00 + (h10 - h00) * tx;
245        let b = h01 + (h11 - h01) * tx;
246        a + (b - a) * tz
247    }
248
249    fn sample_at(&self, x: u32, z: u32) -> f32 {
250        self.heights[(z * self.sample_count[0] + x) as usize]
251    }
252
253    fn normal_at_sample(&self, x: u32, z: u32) -> [f32; 3] {
254        debug_assert!(x < self.sample_count[0]);
255        debug_assert!(z < self.sample_count[1]);
256
257        let max_x = self.sample_count[0] - 1;
258        let max_z = self.sample_count[1] - 1;
259        let left_x = x.saturating_sub(1);
260        let right_x = (x + 1).min(max_x);
261        let down_z = z.saturating_sub(1);
262        let up_z = (z + 1).min(max_z);
263
264        let left = self.sample_at(left_x, z);
265        let right = self.sample_at(right_x, z);
266        let down = self.sample_at(x, down_z);
267        let up = self.sample_at(x, up_z);
268
269        let dx = (right_x - left_x) as f32 * self.pixel_stride[0];
270        let dz = (up_z - down_z) as f32 * self.pixel_stride[1];
271        let dhdx = (right - left) / dx;
272        let dhdz = (up - down) / dz;
273        normalize([-dhdx, 1.0, -dhdz]).expect("normal vector includes +Y component")
274    }
275}
276
277/// CPU-side terrain mesh generated from a [`Terrain`] height grid.
278///
279/// Positions and normals are flat `[x, y, z]` arrays. Positions are local to
280/// the terrain origin: X/Z start at `0.0`, while Y stores the sampled height.
281/// The render entity spawned by [`HeightmapPlugin::with_render_mesh`] carries a
282/// transform at the terrain's world X/Z origin.
283#[derive(Debug, Clone, PartialEq)]
284pub struct TerrainMesh {
285    positions: Vec<f32>,
286    normals: Vec<f32>,
287    indices: Vec<u32>,
288}
289
290impl TerrainMesh {
291    /// Generate a triangle mesh from every source height sample.
292    pub fn from_terrain(terrain: &Terrain) -> Self {
293        let [width, height] = terrain.sample_count();
294        let [stride_x, stride_z] = terrain.pixel_stride();
295        let vertex_count = width as usize * height as usize;
296        let quad_count = (width - 1) as usize * (height - 1) as usize;
297        let mut positions = Vec::with_capacity(vertex_count * 3);
298        let mut normals = Vec::with_capacity(vertex_count * 3);
299        let mut indices = Vec::with_capacity(quad_count * 6);
300
301        for z in 0..height {
302            for x in 0..width {
303                let local_x = x as f32 * stride_x;
304                let local_z = z as f32 * stride_z;
305                positions.extend_from_slice(&[local_x, terrain.sample_at(x, z), local_z]);
306
307                let normal = terrain.normal_at_sample(x, z);
308                normals.extend_from_slice(&normal);
309            }
310        }
311
312        for z in 0..(height - 1) {
313            for x in 0..(width - 1) {
314                let top_left = z * width + x;
315                let top_right = top_left + 1;
316                let bottom_left = top_left + width;
317                let bottom_right = bottom_left + 1;
318                indices.extend_from_slice(&[
319                    top_left,
320                    bottom_left,
321                    top_right,
322                    top_right,
323                    bottom_left,
324                    bottom_right,
325                ]);
326            }
327        }
328
329        Self {
330            positions,
331            normals,
332            indices,
333        }
334    }
335
336    /// Number of generated vertices.
337    pub fn vertex_count(&self) -> usize {
338        self.positions.len() / 3
339    }
340
341    /// Flat `[x, y, z]` vertex positions, local to the terrain origin.
342    pub fn positions(&self) -> &[f32] {
343        &self.positions
344    }
345
346    /// Flat `[x, y, z]` vertex normals.
347    pub fn normals(&self) -> &[f32] {
348        &self.normals
349    }
350
351    /// Triangle index buffer. Winding faces up toward +Y.
352    pub fn indices(&self) -> &[u32] {
353        &self.indices
354    }
355}
356
357/// PNG16 heightmap import options.
358#[derive(Debug, Clone, Copy, PartialEq)]
359pub struct Png16HeightmapOptions {
360    pub origin: [f32; 2],
361    pub size: [f32; 2],
362    pub height_min: f32,
363    pub height_max: f32,
364    pub vertical_exaggeration: f32,
365}
366
367impl Default for Png16HeightmapOptions {
368    fn default() -> Self {
369        Self {
370            origin: [0.0, 0.0],
371            size: [1.0, 1.0],
372            height_min: 0.0,
373            height_max: 1.0,
374            vertical_exaggeration: 1.0,
375        }
376    }
377}
378
379/// Optional render entity settings for [`HeightmapPlugin`].
380#[derive(Debug, Clone, Copy, PartialEq, Eq)]
381pub struct TerrainRenderSettings {
382    pub mesh_handle: MeshHandle,
383    pub material_handle: MaterialHandle,
384}
385
386impl TerrainRenderSettings {
387    pub fn new(mesh_handle: MeshHandle, material_handle: MaterialHandle) -> Self {
388        Self {
389            mesh_handle,
390            material_handle,
391        }
392    }
393}
394
395/// Plugin that installs a loaded [`Terrain`] resource.
396#[derive(Debug, Clone, PartialEq)]
397pub struct HeightmapPlugin {
398    terrain: Terrain,
399    render: Option<TerrainRenderSettings>,
400}
401
402impl HeightmapPlugin {
403    pub fn new(terrain: Terrain) -> Self {
404        Self {
405            terrain,
406            render: None,
407        }
408    }
409
410    /// Also generate a [`TerrainMesh`] resource and spawn one renderable entity.
411    ///
412    /// The spawned entity uses the provided mesh/material handles and a
413    /// transform positioned at the terrain's X/Z origin. Consumers still own
414    /// mapping `mesh_handle` to a Three.js `BufferGeometry`; this method keeps
415    /// the frame packet on the existing renderable-entity channel.
416    pub fn with_render_mesh(
417        mut self,
418        mesh_handle: MeshHandle,
419        material_handle: MaterialHandle,
420    ) -> Self {
421        self.render = Some(TerrainRenderSettings::new(mesh_handle, material_handle));
422        self
423    }
424}
425
426impl Plugin for HeightmapPlugin {
427    fn build(&self, engine: &mut Engine) {
428        if let Some(render) = self.render {
429            engine.insert_resource(TerrainMesh::from_terrain(&self.terrain));
430            let origin = self.terrain.origin();
431            engine.world_mut().spawn((
432                Transform::from_position(origin[0], 0.0, origin[1]),
433                Visibility { visible: true },
434                render.mesh_handle,
435                render.material_handle,
436                ObjectType::Mesh,
437            ));
438        }
439        engine.insert_resource(self.terrain.clone());
440    }
441}
442
443#[derive(Debug)]
444pub enum TerrainError {
445    InvalidDimensions {
446        width: u32,
447        height: u32,
448    },
449    HeightCount {
450        expected: usize,
451        actual: usize,
452    },
453    InvalidOrigin {
454        origin: [f32; 2],
455    },
456    InvalidSize {
457        size: [f32; 2],
458    },
459    NonFiniteHeight,
460    InvalidHeightScale,
461    InvalidHeightRange {
462        min: f32,
463        max: f32,
464    },
465    UnsupportedPng {
466        color_type: png::ColorType,
467        bit_depth: png::BitDepth,
468    },
469    UnknownPngBufferSize,
470    MalformedPngData,
471    PngDecode(png::DecodingError),
472}
473
474impl fmt::Display for TerrainError {
475    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
476        match self {
477            Self::InvalidDimensions { width, height } => {
478                write!(
479                    f,
480                    "terrain dimensions must be at least 2x2, got {width}x{height}"
481                )
482            }
483            Self::HeightCount { expected, actual } => {
484                write!(
485                    f,
486                    "terrain expected {expected} height samples, got {actual}"
487                )
488            }
489            Self::InvalidOrigin { origin } => {
490                write!(f, "terrain origin must be finite, got {origin:?}")
491            }
492            Self::InvalidSize { size } => {
493                write!(f, "terrain size must be finite and positive, got {size:?}")
494            }
495            Self::NonFiniteHeight => write!(f, "terrain heights must be finite"),
496            Self::InvalidHeightScale => write!(f, "PNG height scale values must be finite"),
497            Self::InvalidHeightRange { min, max } => {
498                write!(f, "PNG height_max must be >= height_min, got {max} < {min}")
499            }
500            Self::UnsupportedPng {
501                color_type,
502                bit_depth,
503            } => write!(
504                f,
505                "heightmap PNG must be 16-bit grayscale, got {color_type:?} {bit_depth:?}",
506            ),
507            Self::UnknownPngBufferSize => {
508                write!(f, "PNG decoder did not report output buffer size")
509            }
510            Self::MalformedPngData => write!(f, "PNG frame data had an odd byte count"),
511            Self::PngDecode(err) => write!(f, "PNG decode failed: {err}"),
512        }
513    }
514}
515
516impl Error for TerrainError {
517    fn source(&self) -> Option<&(dyn Error + 'static)> {
518        match self {
519            Self::PngDecode(err) => Some(err),
520            _ => None,
521        }
522    }
523}
524
525fn min_max(values: &[f32]) -> (f32, f32) {
526    let mut min = f32::INFINITY;
527    let mut max = f32::NEG_INFINITY;
528    for value in values {
529        min = min.min(*value);
530        max = max.max(*value);
531    }
532    (min, max)
533}
534
535fn normalize(v: [f32; 3]) -> Option<[f32; 3]> {
536    let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
537    if len <= f32::EPSILON {
538        return None;
539    }
540    Some([v[0] / len, v[1] / len, v[2] / len])
541}
542
543#[cfg(test)]
544mod tests {
545    use std::io::Cursor;
546
547    use super::*;
548
549    fn synthetic_4x4() -> Terrain {
550        Terrain::new(
551            [10.0, 20.0],
552            [3.0, 3.0],
553            4,
554            4,
555            vec![
556                0.0, 1.0, 2.0, 3.0, //
557                1.0, 2.0, 3.0, 4.0, //
558                2.0, 3.0, 4.0, 5.0, //
559                3.0, 4.0, 5.0, 6.0,
560            ],
561        )
562        .unwrap()
563    }
564
565    #[test]
566    fn height_at_bilinear_samples_synthetic_grid() {
567        let terrain = synthetic_4x4();
568
569        assert_eq!(terrain.height_at(10.0, 20.0), Some(0.0));
570        assert_eq!(terrain.height_at(13.0, 23.0), Some(6.0));
571        assert_eq!(terrain.height_at(11.5, 21.5), Some(3.0));
572        assert_eq!(terrain.height_at(9.9, 20.0), None);
573    }
574
575    #[test]
576    fn normal_at_uses_source_height_gradient() {
577        let terrain = synthetic_4x4();
578        let normal = terrain.normal_at(11.0, 21.0).unwrap();
579        let expected = [
580            -1.0 / 3.0_f32.sqrt(),
581            1.0 / 3.0_f32.sqrt(),
582            -1.0 / 3.0_f32.sqrt(),
583        ];
584
585        assert!((normal[0] - expected[0]).abs() < 1e-6);
586        assert!((normal[1] - expected[1]).abs() < 1e-6);
587        assert!((normal[2] - expected[2]).abs() < 1e-6);
588    }
589
590    #[test]
591    fn normal_at_preserves_planar_gradient_near_edges() {
592        let terrain = synthetic_4x4();
593        let interior = terrain.normal_at(11.0, 21.0).unwrap();
594        let near_max_edge = terrain.normal_at(12.9, 22.9).unwrap();
595
596        for i in 0..3 {
597            assert!(
598                (near_max_edge[i] - interior[i]).abs() < 1e-6,
599                "component {i}: expected {interior:?}, got {near_max_edge:?}",
600            );
601        }
602    }
603
604    #[test]
605    fn bounds_and_stride_reflect_loaded_grid() {
606        let terrain = synthetic_4x4();
607
608        assert_eq!(terrain.sample_count(), [4, 4]);
609        assert_eq!(terrain.pixel_stride(), [1.0, 1.0]);
610        assert_eq!(terrain.bounds(), ([10.0, 0.0, 20.0], [13.0, 6.0, 23.0]));
611    }
612
613    #[test]
614    fn heightmap_plugin_inserts_terrain_resource() {
615        let terrain = synthetic_4x4();
616        let height_storage = terrain.heights().as_ptr();
617        let mut engine = Engine::new();
618
619        engine.add_plugin(HeightmapPlugin::new(terrain.clone()));
620
621        assert_eq!(engine.world().resource::<Terrain>(), &terrain);
622        assert_eq!(
623            engine.world().resource::<Terrain>().heights().as_ptr(),
624            height_storage
625        );
626    }
627
628    #[test]
629    fn terrain_mesh_generates_vertices_normals_and_indices() {
630        let terrain = Terrain::new(
631            [0.0, 0.0],
632            [2.0, 2.0],
633            3,
634            3,
635            vec![
636                0.0, 1.0, 2.0, //
637                1.0, 2.0, 3.0, //
638                2.0, 3.0, 4.0,
639            ],
640        )
641        .unwrap();
642
643        let mesh = TerrainMesh::from_terrain(&terrain);
644
645        assert_eq!(mesh.vertex_count(), (2 + 1) * (2 + 1));
646        assert_eq!(mesh.positions().len(), 9 * 3);
647        assert_eq!(mesh.normals().len(), 9 * 3);
648        assert_eq!(mesh.indices().len(), 2 * 2 * 6);
649        assert_eq!(&mesh.positions()[0..3], &[0.0, 0.0, 0.0]);
650        assert_eq!(&mesh.positions()[24..27], &[2.0, 4.0, 2.0]);
651
652        let expected = [
653            -1.0 / 3.0_f32.sqrt(),
654            1.0 / 3.0_f32.sqrt(),
655            -1.0 / 3.0_f32.sqrt(),
656        ];
657        let center_normal = &mesh.normals()[12..15];
658        for i in 0..3 {
659            assert!((center_normal[i] - expected[i]).abs() < 1e-6);
660        }
661    }
662
663    #[test]
664    fn terrain_mesh_computes_edge_normals_from_grid_samples() {
665        let terrain = Terrain::new(
666            [0.0, 0.0],
667            [0.1, 0.1],
668            4,
669            4,
670            vec![
671                0.0, 1.0, 2.0, 3.0, //
672                1.0, 2.0, 3.0, 4.0, //
673                2.0, 3.0, 4.0, 5.0, //
674                3.0, 4.0, 5.0, 6.0,
675            ],
676        )
677        .unwrap();
678
679        let mesh = TerrainMesh::from_terrain(&terrain);
680        let last_normal = &mesh.normals()[45..48];
681        let gradient = 1.0 / (0.1_f32 / 3.0);
682        let expected = normalize([-gradient, 1.0, -gradient]).unwrap();
683
684        for i in 0..3 {
685            assert!(
686                (last_normal[i] - expected[i]).abs() < 1e-6,
687                "component {i}: expected {expected:?}, got {last_normal:?}",
688            );
689        }
690    }
691
692    #[test]
693    fn heightmap_plugin_emits_terrain_render_entity_through_frame_packet() {
694        let terrain = synthetic_4x4();
695        let mut engine = Engine::new();
696
697        engine.add_plugin(
698            HeightmapPlugin::new(terrain)
699                .with_render_mesh(MeshHandle { id: 77 }, MaterialHandle { id: 9 }),
700        );
701
702        let mesh = engine.world().resource::<TerrainMesh>();
703        assert_eq!(mesh.vertex_count(), (4 - 1 + 1) * (4 - 1 + 1));
704
705        let packet = galeon_engine_three_sync::extract_frame(engine.world());
706        assert_eq!(packet.entity_count(), 1);
707        assert_eq!(packet.mesh_handles[0], 77);
708        assert_eq!(packet.material_handles[0], 9);
709        assert_eq!(packet.transforms[0], 10.0);
710        assert_eq!(packet.transforms[1], 0.0);
711        assert_eq!(packet.transforms[2], 20.0);
712    }
713
714    #[test]
715    fn terrain_clone_shares_immutable_height_storage() {
716        let terrain = synthetic_4x4();
717        let clone = terrain.clone();
718
719        assert_eq!(clone, terrain);
720        assert_eq!(clone.heights().as_ptr(), terrain.heights().as_ptr());
721    }
722
723    #[test]
724    fn png16_loader_decodes_fixture_corner_values() {
725        let bytes = include_bytes!("../tests/fixtures/heightmap-16x16-gray16.png");
726        let terrain = Terrain::from_png16_reader(
727            Cursor::new(bytes.as_slice()),
728            Png16HeightmapOptions {
729                origin: [-8.0, -8.0],
730                size: [15.0, 15.0],
731                height_min: -10.0,
732                height_max: 10.0,
733                vertical_exaggeration: 1.5,
734            },
735        )
736        .unwrap();
737
738        assert_eq!(terrain.sample_count(), [16, 16]);
739        assert_eq!(terrain.pixel_stride(), [1.0, 1.0]);
740        assert!((terrain.height_at(-8.0, -8.0).unwrap() - -10.0).abs() < 1e-6);
741        assert!((terrain.height_at(7.0, 7.0).unwrap() - 20.0).abs() < 1e-6);
742        assert_eq!(terrain.min_height(), -10.0);
743        assert_eq!(terrain.max_height(), 20.0);
744    }
745
746    #[test]
747    fn png16_loader_rejects_non_gray16_png() {
748        let mut bytes = Vec::new();
749        {
750            let mut encoder = png::Encoder::new(&mut bytes, 1, 1);
751            encoder.set_color(png::ColorType::Grayscale);
752            encoder.set_depth(png::BitDepth::Eight);
753            let mut writer = encoder.write_header().unwrap();
754            writer.write_image_data(&[0]).unwrap();
755        }
756
757        let err = Terrain::from_png16_reader(Cursor::new(bytes), Png16HeightmapOptions::default())
758            .unwrap_err();
759        assert!(matches!(err, TerrainError::UnsupportedPng { .. }));
760    }
761
762    #[test]
763    fn sample_bilinear_clamps_coordinates_above_max_index() {
764        let terrain = synthetic_4x4();
765
766        let height = terrain.sample_bilinear(4.0, 4.0);
767
768        assert_eq!(height, 6.0);
769    }
770}