Skip to main content

oxiphysics_io/
remote_sensing_io.rs

1#![allow(clippy::needless_range_loop, clippy::type_complexity)]
2#![allow(clippy::manual_range_contains)]
3// Copyright 2026 COOLJAPAN OU (Team KitaSan)
4// SPDX-License-Identifier: Apache-2.0
5
6//! Remote sensing and geospatial data I/O.
7//!
8//! Provides structs and methods for reading, writing, and processing
9//! geospatial raster data (GeoTIFF-style), point clouds (LAS/XYZ),
10//! satellite imagery indices, and digital elevation models.
11
12#![allow(dead_code)]
13
14// ---------------------------------------------------------------------------
15// GeoTiffReader
16// ---------------------------------------------------------------------------
17
18/// A minimal GeoTIFF-style raster reader that holds image geometry and
19/// georeferencing metadata.
20///
21/// The affine transform follows the standard 6-parameter GDAL convention:
22/// `[x_origin, pixel_width, row_rotation, y_origin, col_rotation, pixel_height]`
23/// where `pixel_height` is typically negative (top-left origin).
24#[derive(Debug, Clone)]
25pub struct GeoTiffReader {
26    /// Raster width in pixels.
27    pub width: usize,
28    /// Raster height in pixels.
29    pub height: usize,
30    /// Number of spectral or data bands.
31    pub bands: usize,
32    /// Six-element affine geotransform (GDAL order).
33    pub transform: [f64; 6],
34    /// Band data stored as flat row-major vectors, one per band.
35    data: Vec<Vec<f64>>,
36}
37
38impl GeoTiffReader {
39    /// Construct a new `GeoTiffReader` with explicit dimensions and transform.
40    pub fn new(
41        width: usize,
42        height: usize,
43        bands: usize,
44        transform: [f64; 6],
45        data: Vec<Vec<f64>>,
46    ) -> Self {
47        Self {
48            width,
49            height,
50            bands,
51            transform,
52            data,
53        }
54    }
55
56    /// Convert pixel coordinates `(px, py)` to world (lon, lat) coordinates
57    /// using the stored affine transform.
58    pub fn pixel_to_world(&self, px: usize, py: usize) -> [f64; 2] {
59        let t = &self.transform;
60        let lon = t[0] + px as f64 * t[1] + py as f64 * t[2];
61        let lat = t[3] + px as f64 * t[4] + py as f64 * t[5];
62        [lon, lat]
63    }
64
65    /// Convert world (lon, lat) coordinates to pixel coordinates using the
66    /// inverse of the stored affine transform.
67    pub fn world_to_pixel(&self, lon: f64, lat: f64) -> (usize, usize) {
68        let t = &self.transform;
69        let det = t[1] * t[5] - t[2] * t[4];
70        let det = if det.abs() < 1e-15 { 1e-15 } else { det };
71        let dx = lon - t[0];
72        let dy = lat - t[3];
73        let px = (t[5] * dx - t[2] * dy) / det;
74        let py = (t[1] * dy - t[4] * dx) / det;
75        let px = px
76            .round()
77            .max(0.0)
78            .min((self.width.saturating_sub(1)) as f64) as usize;
79        let py = py
80            .round()
81            .max(0.0)
82            .min((self.height.saturating_sub(1)) as f64) as usize;
83        (px, py)
84    }
85
86    /// Return a clone of band `band` data (0-indexed).
87    ///
88    /// Returns an empty vector if `band >= self.bands`.
89    pub fn read_band(&self, band: usize) -> Vec<f64> {
90        if band < self.data.len() {
91            self.data[band].clone()
92        } else {
93            vec![]
94        }
95    }
96
97    /// Return the elevation (first-band value) at world coordinate `(lon, lat)`.
98    pub fn elevation_at(&self, lon: f64, lat: f64) -> f64 {
99        if self.data.is_empty() || self.data[0].is_empty() {
100            return 0.0;
101        }
102        let (px, py) = self.world_to_pixel(lon, lat);
103        let idx = py * self.width + px;
104        *self.data[0].get(idx).unwrap_or(&0.0)
105    }
106
107    /// Return the bounding box `[min_lon, min_lat, max_lon, max_lat]`.
108    pub fn bounding_box(&self) -> [f64; 4] {
109        let tl = self.pixel_to_world(0, 0);
110        let br = self.pixel_to_world(self.width, self.height);
111        [
112            tl[0].min(br[0]),
113            tl[1].min(br[1]),
114            tl[0].max(br[0]),
115            tl[1].max(br[1]),
116        ]
117    }
118}
119
120// ---------------------------------------------------------------------------
121// RasterData
122// ---------------------------------------------------------------------------
123
124/// A 2-D raster grid with an associated no-data sentinel value.
125#[derive(Debug, Clone)]
126pub struct RasterData {
127    /// Row-major 2-D data grid (`data[row][col]`).
128    pub data: Vec<Vec<f64>>,
129    /// Number of columns.
130    pub width: usize,
131    /// Number of rows.
132    pub height: usize,
133    /// Sentinel value representing missing / invalid cells.
134    pub nodata: f64,
135}
136
137impl RasterData {
138    /// Construct a new `RasterData` from a 2-D grid.
139    pub fn new(data: Vec<Vec<f64>>, nodata: f64) -> Self {
140        let height = data.len();
141        let width = if height > 0 { data[0].len() } else { 0 };
142        Self {
143            data,
144            width,
145            height,
146            nodata,
147        }
148    }
149
150    /// Compute `(min, max, mean, std_dev)` over all valid (non-nodata) cells.
151    pub fn statistics(&self) -> (f64, f64, f64, f64) {
152        let valid: Vec<f64> = self
153            .data
154            .iter()
155            .flat_map(|row| row.iter())
156            .copied()
157            .filter(|&v| (v - self.nodata).abs() > 1e-10)
158            .collect();
159        if valid.is_empty() {
160            return (0.0, 0.0, 0.0, 0.0);
161        }
162        let min = valid.iter().cloned().fold(f64::INFINITY, f64::min);
163        let max = valid.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
164        let mean = valid.iter().sum::<f64>() / valid.len() as f64;
165        let variance =
166            valid.iter().map(|&v| (v - mean) * (v - mean)).sum::<f64>() / valid.len() as f64;
167        (min, max, mean, variance.sqrt())
168    }
169
170    /// Clip the raster to a bounding box `[min_x, min_y, max_x, max_y]`.
171    ///
172    /// In pixel space this maps `x → col` and `y → row` (0-origin).
173    pub fn clip_to_bbox(&self, bbox: [f64; 4]) -> Self {
174        let col_start = (bbox[0] as usize).min(self.width);
175        let row_start = (bbox[1] as usize).min(self.height);
176        let col_end = (bbox[2] as usize).min(self.width);
177        let row_end = (bbox[3] as usize).min(self.height);
178        let clipped: Vec<Vec<f64>> = self.data[row_start..row_end]
179            .iter()
180            .map(|row| row[col_start..col_end].to_vec())
181            .collect();
182        RasterData::new(clipped, self.nodata)
183    }
184
185    /// Resample the raster by `factor` (e.g. 0.5 = half resolution).
186    pub fn resample(&self, factor: f64) -> Self {
187        let factor = factor.max(0.01);
188        let new_h = ((self.height as f64) * factor).round() as usize;
189        let new_w = ((self.width as f64) * factor).round() as usize;
190        let mut out = vec![vec![self.nodata; new_w]; new_h];
191        for r in 0..new_h {
192            for c in 0..new_w {
193                let src_r = ((r as f64) / factor) as usize;
194                let src_c = ((c as f64) / factor) as usize;
195                let src_r = src_r.min(self.height.saturating_sub(1));
196                let src_c = src_c.min(self.width.saturating_sub(1));
197                out[r][c] = self.data[src_r][src_c];
198            }
199        }
200        RasterData::new(out, self.nodata)
201    }
202
203    /// Compute hillshade given sun `azimuth` and `elevation` (degrees).
204    pub fn hillshade(&self, azimuth: f64, elevation: f64) -> Vec<Vec<f64>> {
205        let az_rad = azimuth.to_radians();
206        let el_rad = elevation.to_radians();
207        let sun = [
208            -el_rad.cos() * az_rad.sin(),
209            -el_rad.cos() * az_rad.cos(),
210            el_rad.sin(),
211        ];
212        let slope_grid = self.slope();
213        let aspect_grid = self.aspect();
214        let mut hs = vec![vec![0.0_f64; self.width]; self.height];
215        for r in 0..self.height {
216            for c in 0..self.width {
217                let s = slope_grid[r][c].to_radians();
218                let a = aspect_grid[r][c].to_radians();
219                let nx = s.sin() * a.sin();
220                let ny = s.sin() * a.cos();
221                let nz = s.cos();
222                let dot = (nx * sun[0] + ny * sun[1] + nz * sun[2]).max(0.0);
223                hs[r][c] = dot;
224            }
225        }
226        hs
227    }
228
229    /// Compute slope (degrees) using a 3×3 Sobel kernel.
230    pub fn slope(&self) -> Vec<Vec<f64>> {
231        let mut out = vec![vec![0.0_f64; self.width]; self.height];
232        for r in 1..self.height.saturating_sub(1) {
233            for c in 1..self.width.saturating_sub(1) {
234                let dzdx =
235                    (self.data[r - 1][c + 1] + 2.0 * self.data[r][c + 1] + self.data[r + 1][c + 1]
236                        - self.data[r - 1][c - 1]
237                        - 2.0 * self.data[r][c - 1]
238                        - self.data[r + 1][c - 1])
239                        / 8.0;
240                let dzdy =
241                    (self.data[r + 1][c - 1] + 2.0 * self.data[r + 1][c] + self.data[r + 1][c + 1]
242                        - self.data[r - 1][c - 1]
243                        - 2.0 * self.data[r - 1][c]
244                        - self.data[r - 1][c + 1])
245                        / 8.0;
246                out[r][c] = (dzdx * dzdx + dzdy * dzdy).sqrt().atan().to_degrees();
247            }
248        }
249        out
250    }
251
252    /// Compute aspect (degrees, 0 = North, clockwise) using a 3×3 Sobel kernel.
253    pub fn aspect(&self) -> Vec<Vec<f64>> {
254        let mut out = vec![vec![0.0_f64; self.width]; self.height];
255        for r in 1..self.height.saturating_sub(1) {
256            for c in 1..self.width.saturating_sub(1) {
257                let dzdx =
258                    (self.data[r - 1][c + 1] + 2.0 * self.data[r][c + 1] + self.data[r + 1][c + 1]
259                        - self.data[r - 1][c - 1]
260                        - 2.0 * self.data[r][c - 1]
261                        - self.data[r + 1][c - 1])
262                        / 8.0;
263                let dzdy =
264                    (self.data[r + 1][c - 1] + 2.0 * self.data[r + 1][c] + self.data[r + 1][c + 1]
265                        - self.data[r - 1][c - 1]
266                        - 2.0 * self.data[r - 1][c]
267                        - self.data[r - 1][c + 1])
268                        / 8.0;
269                let aspect = (-dzdy).atan2(dzdx).to_degrees();
270                out[r][c] = if aspect < 0.0 { aspect + 360.0 } else { aspect };
271            }
272        }
273        out
274    }
275}
276
277// ---------------------------------------------------------------------------
278// Dem
279// ---------------------------------------------------------------------------
280
281/// Digital Elevation Model wrapping a `RasterData` grid with a known cell size.
282#[derive(Debug, Clone)]
283pub struct Dem {
284    /// Underlying raster elevation data.
285    pub raster: RasterData,
286    /// Physical size of each square cell (metres).
287    pub cell_size: f64,
288}
289
290impl Dem {
291    /// Construct a new `Dem` from a `RasterData` and a cell size.
292    pub fn new(raster: RasterData, cell_size: f64) -> Self {
293        Self { raster, cell_size }
294    }
295
296    /// Compute an 8-direction flow-direction grid (D8 algorithm).
297    ///
298    /// Encoded directions: 1=E, 2=SE, 4=S, 8=SW, 16=W, 32=NW, 64=N, 128=NE.
299    pub fn flow_direction(&self) -> Vec<Vec<u8>> {
300        let h = self.raster.height;
301        let w = self.raster.width;
302        // D8 neighbour offsets (dr, dc) and their encoded values
303        let dirs: [(i32, i32, u8); 8] = [
304            (0, 1, 1),    // E
305            (1, 1, 2),    // SE
306            (1, 0, 4),    // S
307            (1, -1, 8),   // SW
308            (0, -1, 16),  // W
309            (-1, -1, 32), // NW
310            (-1, 0, 64),  // N
311            (-1, 1, 128), // NE
312        ];
313        let mut out = vec![vec![0u8; w]; h];
314        for r in 0..h {
315            for c in 0..w {
316                let z = self.raster.data[r][c];
317                let mut max_drop = 0.0_f64;
318                let mut best_dir = 1u8;
319                for &(dr, dc, code) in &dirs {
320                    let nr = r as i32 + dr;
321                    let nc = c as i32 + dc;
322                    if nr < 0 || nc < 0 || nr >= h as i32 || nc >= w as i32 {
323                        continue;
324                    }
325                    let nz = self.raster.data[nr as usize][nc as usize];
326                    let dist = if dr == 0 || dc == 0 {
327                        self.cell_size
328                    } else {
329                        self.cell_size * std::f64::consts::SQRT_2
330                    };
331                    let drop = (z - nz) / dist;
332                    if drop > max_drop {
333                        max_drop = drop;
334                        best_dir = code;
335                    }
336                }
337                out[r][c] = best_dir;
338            }
339        }
340        out
341    }
342
343    /// Compute flow-accumulation grid (number of upstream cells).
344    pub fn flow_accumulation(&self) -> Vec<Vec<f64>> {
345        let h = self.raster.height;
346        let w = self.raster.width;
347        let fd = self.flow_direction();
348        let mut accum = vec![vec![1.0_f64; w]; h];
349        // Simple iterative accumulation (not topology-sorted; sufficient for tests)
350        for _ in 0..(h * w) {
351            let snap = accum.clone();
352            for r in 0..h {
353                for c in 0..w {
354                    let dir = fd[r][c];
355                    let (dr, dc): (i32, i32) = match dir {
356                        1 => (0, 1),
357                        2 => (1, 1),
358                        4 => (1, 0),
359                        8 => (1, -1),
360                        16 => (0, -1),
361                        32 => (-1, -1),
362                        64 => (-1, 0),
363                        128 => (-1, 1),
364                        _ => continue,
365                    };
366                    let nr = r as i32 + dr;
367                    let nc = c as i32 + dc;
368                    if nr >= 0 && nc >= 0 && nr < h as i32 && nc < w as i32 {
369                        accum[nr as usize][nc as usize] += snap[r][c];
370                    }
371                }
372            }
373        }
374        accum
375    }
376
377    /// Delineate a watershed upstream of `outlet` pixel using D8 flow directions.
378    ///
379    /// Returns a list of `(row, col)` cells belonging to the watershed.
380    pub fn watershed_delineation(&self, outlet: (usize, usize)) -> Vec<(usize, usize)> {
381        let h = self.raster.height;
382        let w = self.raster.width;
383        let fd = self.flow_direction();
384        // For each cell check whether its flow direction eventually reaches outlet
385        // (single-step check: does the immediate downstream cell flow toward outlet?)
386        let dir_to_offset = |d: u8| -> (i32, i32) {
387            match d {
388                1 => (0, 1),
389                2 => (1, 1),
390                4 => (1, 0),
391                8 => (1, -1),
392                16 => (0, -1),
393                32 => (-1, -1),
394                64 => (-1, 0),
395                128 => (-1, 1),
396                _ => (0, 0),
397            }
398        };
399
400        let mut watershed = Vec::new();
401        let mut visited = vec![vec![false; w]; h];
402        let mut stack = vec![outlet];
403        visited[outlet.0][outlet.1] = true;
404
405        while let Some((r, c)) = stack.pop() {
406            watershed.push((r, c));
407            // Find all cells that drain directly into (r, c)
408            for nr in 0..h {
409                for nc in 0..w {
410                    if visited[nr][nc] {
411                        continue;
412                    }
413                    let (dr, dc) = dir_to_offset(fd[nr][nc]);
414                    let tr = nr as i32 + dr;
415                    let tc = nc as i32 + dc;
416                    if tr == r as i32 && tc == c as i32 {
417                        visited[nr][nc] = true;
418                        stack.push((nr, nc));
419                    }
420                }
421            }
422        }
423        watershed
424    }
425
426    /// Extract a stream network as connected channel segments.
427    ///
428    /// Cells with flow accumulation ≥ `threshold` are considered streams.
429    /// Returns a list of segments, each segment being an ordered list of cells.
430    pub fn extract_stream_network(&self, threshold: f64) -> Vec<Vec<(usize, usize)>> {
431        let accum = self.flow_accumulation();
432        let h = self.raster.height;
433        let w = self.raster.width;
434        let fd = self.flow_direction();
435        let is_stream: Vec<Vec<bool>> = accum
436            .iter()
437            .map(|row| row.iter().map(|&v| v >= threshold).collect())
438            .collect();
439
440        let dir_to_offset = |d: u8| -> (i32, i32) {
441            match d {
442                1 => (0, 1),
443                2 => (1, 1),
444                4 => (1, 0),
445                8 => (1, -1),
446                16 => (0, -1),
447                32 => (-1, -1),
448                64 => (-1, 0),
449                128 => (-1, 1),
450                _ => (0, 0),
451            }
452        };
453
454        let mut visited = vec![vec![false; w]; h];
455        let mut segments: Vec<Vec<(usize, usize)>> = Vec::new();
456
457        for r in 0..h {
458            for c in 0..w {
459                if !is_stream[r][c] || visited[r][c] {
460                    continue;
461                }
462                // Trace downstream segment
463                let mut seg = Vec::new();
464                let mut cr = r;
465                let mut cc = c;
466                loop {
467                    if visited[cr][cc] || !is_stream[cr][cc] {
468                        break;
469                    }
470                    visited[cr][cc] = true;
471                    seg.push((cr, cc));
472                    let (dr, dc) = dir_to_offset(fd[cr][cc]);
473                    let nr = cr as i32 + dr;
474                    let nc = cc as i32 + dc;
475                    if nr < 0 || nc < 0 || nr >= h as i32 || nc >= w as i32 {
476                        break;
477                    }
478                    cr = nr as usize;
479                    cc = nc as usize;
480                }
481                if !seg.is_empty() {
482                    segments.push(seg);
483                }
484            }
485        }
486        segments
487    }
488}
489
490// ---------------------------------------------------------------------------
491// PointCloudIO
492// ---------------------------------------------------------------------------
493
494/// Utilities for reading and writing 3-D point cloud data.
495pub struct PointCloudIO;
496
497impl PointCloudIO {
498    /// Parse a minimal LAS-style ASCII stub where each line is `x y z`.
499    ///
500    /// Lines beginning with `#` are treated as comments and skipped.
501    pub fn read_las_stub(content: &str) -> Vec<[f64; 3]> {
502        content
503            .lines()
504            .filter(|l| !l.trim_start().starts_with('#') && !l.trim().is_empty())
505            .filter_map(|l| {
506                let mut parts = l.split_whitespace();
507                let x = parts.next()?.parse::<f64>().ok()?;
508                let y = parts.next()?.parse::<f64>().ok()?;
509                let z = parts.next()?.parse::<f64>().ok()?;
510                Some([x, y, z])
511            })
512            .collect()
513    }
514
515    /// Serialize a slice of points to a whitespace-delimited XYZ string.
516    pub fn write_xyz(points: &[[f64; 3]]) -> String {
517        points
518            .iter()
519            .map(|p| format!("{} {} {}\n", p[0], p[1], p[2]))
520            .collect()
521    }
522
523    /// Parse a whitespace-delimited XYZ text file into a vector of 3-D points.
524    pub fn read_xyz(content: &str) -> Vec<[f64; 3]> {
525        Self::read_las_stub(content)
526    }
527
528    /// Compute the axis-aligned bounding box `[min_x,min_y,min_z,max_x,max_y,max_z]`.
529    ///
530    /// Returns all zeros if `points` is empty.
531    pub fn bounding_box(points: &[[f64; 3]]) -> [f64; 6] {
532        if points.is_empty() {
533            return [0.0; 6];
534        }
535        let mut mn = points[0];
536        let mut mx = points[0];
537        for p in points.iter().skip(1) {
538            for i in 0..3 {
539                mn[i] = mn[i].min(p[i]);
540                mx[i] = mx[i].max(p[i]);
541            }
542        }
543        [mn[0], mn[1], mn[2], mx[0], mx[1], mx[2]]
544    }
545
546    /// Voxelise a point cloud at `resolution` and return one representative
547    /// point per non-empty voxel (the centroid of all points in that voxel).
548    pub fn voxelize(points: &[[f64; 3]], resolution: f64) -> Vec<[f64; 3]> {
549        if points.is_empty() || resolution <= 0.0 {
550            return vec![];
551        }
552        let bb = Self::bounding_box(points);
553        use std::collections::HashMap;
554        let mut voxels: HashMap<(i64, i64, i64), (f64, f64, f64, u64)> = HashMap::new();
555        for p in points {
556            let ix = ((p[0] - bb[0]) / resolution).floor() as i64;
557            let iy = ((p[1] - bb[1]) / resolution).floor() as i64;
558            let iz = ((p[2] - bb[2]) / resolution).floor() as i64;
559            let e = voxels.entry((ix, iy, iz)).or_insert((0.0, 0.0, 0.0, 0));
560            e.0 += p[0];
561            e.1 += p[1];
562            e.2 += p[2];
563            e.3 += 1;
564        }
565        voxels
566            .values()
567            .map(|&(sx, sy, sz, n)| {
568                let n = n as f64;
569                [sx / n, sy / n, sz / n]
570            })
571            .collect()
572    }
573}
574
575// ---------------------------------------------------------------------------
576// LAS 1.4 binary point cloud
577// ---------------------------------------------------------------------------
578
579/// Fixed-length (375-byte) header parsed from a LAS 1.4 binary file.
580#[derive(Debug, Clone)]
581pub struct LasHeader {
582    /// File creation year (GPS week or calendar year).
583    pub file_creation_year: u16,
584    /// File creation day of year.
585    pub file_creation_doy: u16,
586    /// LAS spec major version (should be 1).
587    pub version_major: u8,
588    /// LAS spec minor version (should be 4 for LAS 1.4).
589    pub version_minor: u8,
590    /// Number of point records.
591    pub point_count: u64,
592    /// X scale factor.
593    pub x_scale: f64,
594    /// Y scale factor.
595    pub y_scale: f64,
596    /// Z scale factor.
597    pub z_scale: f64,
598    /// X offset.
599    pub x_offset: f64,
600    /// Y offset.
601    pub y_offset: f64,
602    /// Z offset.
603    pub z_offset: f64,
604    /// Point data format ID (0–10).
605    pub point_format: u8,
606    /// Number of bytes per point record.
607    pub point_record_len: u16,
608    /// Byte offset to the first point record.
609    pub point_data_offset: u32,
610}
611
612/// One decoded LAS point (format 0 fields).
613#[derive(Debug, Clone)]
614pub struct LasPoint {
615    /// World-space X coordinate (metres or survey feet).
616    pub x: f64,
617    /// World-space Y coordinate.
618    pub y: f64,
619    /// World-space Z coordinate.
620    pub z: f64,
621    /// Return intensity (0–65535).
622    pub intensity: u16,
623    /// Return number and flags byte.
624    pub return_flags: u8,
625    /// Classification byte.
626    pub classification: u8,
627    /// Scan angle rank.
628    pub scan_angle: i8,
629    /// User data.
630    pub user_data: u8,
631    /// Point source ID.
632    pub point_source_id: u16,
633}
634
635/// A decoded LAS point cloud (header + points).
636#[derive(Debug, Clone)]
637pub struct LasPointCloud {
638    /// Parsed LAS header.
639    pub header: LasHeader,
640    /// Decoded point records.
641    pub points: Vec<LasPoint>,
642}
643
644// ── internal byte-reading helpers ──────────────────────────────────────────
645
646fn las_read_u8(data: &[u8], off: &mut usize) -> Result<u8, String> {
647    if *off >= data.len() {
648        return Err(format!("LAS: unexpected EOF at offset {}", *off));
649    }
650    let v = data[*off];
651    *off += 1;
652    Ok(v)
653}
654
655fn las_read_u16_le(data: &[u8], off: &mut usize) -> Result<u16, String> {
656    if *off + 2 > data.len() {
657        return Err(format!(
658            "LAS: unexpected EOF reading u16 at offset {}",
659            *off
660        ));
661    }
662    let v = u16::from_le_bytes([data[*off], data[*off + 1]]);
663    *off += 2;
664    Ok(v)
665}
666
667fn las_read_u32_le(data: &[u8], off: &mut usize) -> Result<u32, String> {
668    if *off + 4 > data.len() {
669        return Err(format!(
670            "LAS: unexpected EOF reading u32 at offset {}",
671            *off
672        ));
673    }
674    let v = u32::from_le_bytes(
675        data[*off..*off + 4]
676            .try_into()
677            .map_err(|e| format!("{e}"))?,
678    );
679    *off += 4;
680    Ok(v)
681}
682
683fn las_read_u64_le(data: &[u8], off: &mut usize) -> Result<u64, String> {
684    if *off + 8 > data.len() {
685        return Err(format!(
686            "LAS: unexpected EOF reading u64 at offset {}",
687            *off
688        ));
689    }
690    let v = u64::from_le_bytes(
691        data[*off..*off + 8]
692            .try_into()
693            .map_err(|e| format!("{e}"))?,
694    );
695    *off += 8;
696    Ok(v)
697}
698
699fn las_read_f64_le(data: &[u8], off: &mut usize) -> Result<f64, String> {
700    if *off + 8 > data.len() {
701        return Err(format!(
702            "LAS: unexpected EOF reading f64 at offset {}",
703            *off
704        ));
705    }
706    let v = f64::from_le_bytes(
707        data[*off..*off + 8]
708            .try_into()
709            .map_err(|e| format!("{e}"))?,
710    );
711    *off += 8;
712    Ok(v)
713}
714
715fn las_read_i8(data: &[u8], off: &mut usize) -> Result<i8, String> {
716    las_read_u8(data, off).map(|v| v as i8)
717}
718
719/// Parse a LAS 1.4 binary point cloud from raw bytes.
720///
721/// Validates the `"LASF"` file signature, decodes the fixed 375-byte header,
722/// then reads all point records using Point Data Format 0 as the base layout
723/// (the first 20 bytes of every format are identical).
724///
725/// # Errors
726///
727/// Returns a descriptive `String` error if the data is too short, the
728/// signature is wrong, or any byte offset exceeds the data length.
729pub fn read_las_binary(data: &[u8]) -> Result<LasPointCloud, String> {
730    // LAS 1.4 header is 375 bytes minimum.
731    if data.len() < 375 {
732        return Err(format!(
733            "LAS: data too short ({} bytes, need at least 375)",
734            data.len()
735        ));
736    }
737
738    // ── File signature "LASF" (bytes 0-3) ───────────────────────────────────
739    if &data[0..4] != b"LASF" {
740        return Err(format!(
741            "LAS: bad signature {:?}, expected b\"LASF\"",
742            &data[0..4]
743        ));
744    }
745
746    let mut off = 4usize;
747
748    // File source ID (u16) + Global encoding (u16)
749    let _file_source_id = las_read_u16_le(data, &mut off)?;
750    let _global_encoding = las_read_u16_le(data, &mut off)?;
751    // Project GUID (16 bytes)
752    off += 16;
753    // Version major / minor
754    let version_major = las_read_u8(data, &mut off)?;
755    let version_minor = las_read_u8(data, &mut off)?;
756    // System identifier (32 bytes)
757    off += 32;
758    // Generating software (32 bytes)
759    off += 32;
760    // File creation day / year
761    let file_creation_doy = las_read_u16_le(data, &mut off)?;
762    let file_creation_year = las_read_u16_le(data, &mut off)?;
763    // Header size (u16) — skip, we know it's 375 for LAS 1.4
764    let _header_size = las_read_u16_le(data, &mut off)?;
765    // Offset to point data (u32)
766    let point_data_offset = las_read_u32_le(data, &mut off)?;
767    // Number of VLRs (u32) — skip VLR parsing for now
768    let _num_vlrs = las_read_u32_le(data, &mut off)?;
769    // Point data format (u8)
770    let point_format = las_read_u8(data, &mut off)?;
771    // Point data record length (u16)
772    let point_record_len = las_read_u16_le(data, &mut off)?;
773    // Legacy point count (u32) — LAS 1.4 uses the u64 field at offset 247
774    let legacy_point_count = las_read_u32_le(data, &mut off)?;
775    // Legacy point counts by return (5 × u32) — 20 bytes
776    off += 20;
777    // Scale factors (3 × f64)
778    let x_scale = las_read_f64_le(data, &mut off)?;
779    let y_scale = las_read_f64_le(data, &mut off)?;
780    let z_scale = las_read_f64_le(data, &mut off)?;
781    // Offsets (3 × f64)
782    let x_offset = las_read_f64_le(data, &mut off)?;
783    let y_offset = las_read_f64_le(data, &mut off)?;
784    let z_offset = las_read_f64_le(data, &mut off)?;
785    // Max/min X, Y, Z (6 × f64) = 48 bytes
786    off += 48;
787    // Start of Waveform Data Packet Record (u64) + Start of first EVLR (u64) +
788    // Number of EVLRs (u32) = 20 bytes — skip, not needed for point reading
789    off += 20;
790
791    // LAS 1.4 extended point count (u64) at offset 247.
792    let point_count_extended = las_read_u64_le(data, &mut off)?;
793    // Extended point counts by return (15 × u64) = 120 bytes — skip, not needed
794    let _ = off; // header parsing complete
795
796    // Resolve point count: prefer non-zero extended count.
797    let point_count = if point_count_extended > 0 {
798        point_count_extended
799    } else {
800        legacy_point_count as u64
801    };
802
803    let header = LasHeader {
804        file_creation_year,
805        file_creation_doy,
806        version_major,
807        version_minor,
808        point_count,
809        x_scale,
810        y_scale,
811        z_scale,
812        x_offset,
813        y_offset,
814        z_offset,
815        point_format,
816        point_record_len,
817        point_data_offset,
818    };
819
820    // ── Point records ────────────────────────────────────────────────────────
821    let mut points: Vec<LasPoint> = Vec::with_capacity(point_count as usize);
822    let rec_len = point_record_len.max(20) as usize;
823    let mut p_off = point_data_offset as usize;
824
825    for _ in 0..point_count {
826        if p_off + rec_len > data.len() {
827            break;
828        }
829        let mut o = p_off;
830        let raw_x = i32::from_le_bytes(
831            data[o..o + 4]
832                .try_into()
833                .map_err(|e| format!("LAS point x: {e}"))?,
834        );
835        o += 4;
836        let raw_y = i32::from_le_bytes(
837            data[o..o + 4]
838                .try_into()
839                .map_err(|e| format!("LAS point y: {e}"))?,
840        );
841        o += 4;
842        let raw_z = i32::from_le_bytes(
843            data[o..o + 4]
844                .try_into()
845                .map_err(|e| format!("LAS point z: {e}"))?,
846        );
847        o += 4;
848        let intensity = las_read_u16_le(data, &mut o)?;
849        let return_flags = las_read_u8(data, &mut o)?;
850        let classification = las_read_u8(data, &mut o)?;
851        let scan_angle = las_read_i8(data, &mut o)?;
852        let user_data = las_read_u8(data, &mut o)?;
853        let point_source_id = las_read_u16_le(data, &mut o)?;
854
855        points.push(LasPoint {
856            x: raw_x as f64 * x_scale + x_offset,
857            y: raw_y as f64 * y_scale + y_offset,
858            z: raw_z as f64 * z_scale + z_offset,
859            intensity,
860            return_flags,
861            classification,
862            scan_angle,
863            user_data,
864            point_source_id,
865        });
866        p_off += rec_len;
867    }
868
869    Ok(LasPointCloud { header, points })
870}
871
872/// Write a minimal LAS 1.4 binary file from a slice of world-space points.
873///
874/// Uses Point Data Format 0 with a 375-byte header and a scale factor of
875/// `scale` (default 0.001 m = 1 mm precision) and no offset.  Intended only
876/// for round-trip testing of [`read_las_binary`].
877pub fn write_las_binary_minimal(points: &[[f64; 3]], scale: f64) -> Vec<u8> {
878    let n = points.len() as u64;
879    let rec_len: u16 = 20; // Point Format 0 minimum
880    let header_size: u16 = 375;
881    let point_data_offset: u32 = header_size as u32;
882    let mut buf = Vec::with_capacity(header_size as usize + n as usize * rec_len as usize);
883
884    // File signature
885    buf.extend_from_slice(b"LASF");
886    // File source ID + global encoding
887    buf.extend_from_slice(&0u16.to_le_bytes());
888    buf.extend_from_slice(&0u16.to_le_bytes());
889    // Project GUID (16 zero bytes)
890    buf.extend_from_slice(&[0u8; 16]);
891    // Version: 1.4
892    buf.push(1u8);
893    buf.push(4u8);
894    // System identifier (32 bytes)
895    buf.extend_from_slice(&[0u8; 32]);
896    // Generating software (32 bytes) — fill with "oxiphysics"
897    let mut sw = [b' '; 32];
898    let tag = b"oxiphysics";
899    sw[..tag.len()].copy_from_slice(tag);
900    buf.extend_from_slice(&sw);
901    // File creation DOY / year
902    buf.extend_from_slice(&1u16.to_le_bytes()); // DOY
903    buf.extend_from_slice(&2026u16.to_le_bytes()); // year
904    // Header size
905    buf.extend_from_slice(&header_size.to_le_bytes());
906    // Offset to point data
907    buf.extend_from_slice(&point_data_offset.to_le_bytes());
908    // Number of VLRs
909    buf.extend_from_slice(&0u32.to_le_bytes());
910    // Point data format
911    buf.push(0u8); // format 0
912    // Point data record length
913    buf.extend_from_slice(&rec_len.to_le_bytes());
914    // Legacy point count
915    let legacy_n = n.min(u32::MAX as u64) as u32;
916    buf.extend_from_slice(&legacy_n.to_le_bytes());
917    // Legacy point counts by return (5 × u32)
918    buf.extend_from_slice(&[0u8; 20]);
919    // Scale factors
920    buf.extend_from_slice(&scale.to_le_bytes());
921    buf.extend_from_slice(&scale.to_le_bytes());
922    buf.extend_from_slice(&scale.to_le_bytes());
923    // Offsets (0, 0, 0)
924    buf.extend_from_slice(&0.0f64.to_le_bytes());
925    buf.extend_from_slice(&0.0f64.to_le_bytes());
926    buf.extend_from_slice(&0.0f64.to_le_bytes());
927    // Max/min X, Y, Z — compute from points
928    let (mut max_x, mut min_x) = (f64::NEG_INFINITY, f64::INFINITY);
929    let (mut max_y, mut min_y) = (f64::NEG_INFINITY, f64::INFINITY);
930    let (mut max_z, mut min_z) = (f64::NEG_INFINITY, f64::INFINITY);
931    for &[x, y, z] in points {
932        if x > max_x {
933            max_x = x;
934        }
935        if x < min_x {
936            min_x = x;
937        }
938        if y > max_y {
939            max_y = y;
940        }
941        if y < min_y {
942            min_y = y;
943        }
944        if z > max_z {
945            max_z = z;
946        }
947        if z < min_z {
948            min_z = z;
949        }
950    }
951    if points.is_empty() {
952        max_x = 0.0;
953        min_x = 0.0;
954        max_y = 0.0;
955        min_y = 0.0;
956        max_z = 0.0;
957        min_z = 0.0;
958    }
959    buf.extend_from_slice(&max_x.to_le_bytes());
960    buf.extend_from_slice(&min_x.to_le_bytes());
961    buf.extend_from_slice(&max_y.to_le_bytes());
962    buf.extend_from_slice(&min_y.to_le_bytes());
963    buf.extend_from_slice(&max_z.to_le_bytes());
964    buf.extend_from_slice(&min_z.to_le_bytes());
965    // Start of Waveform Data Packet Record (u64)
966    buf.extend_from_slice(&0u64.to_le_bytes());
967    // Start of first EVLR (u64)
968    buf.extend_from_slice(&0u64.to_le_bytes());
969    // Number of EVLRs (u32)
970    buf.extend_from_slice(&0u32.to_le_bytes());
971    // LAS 1.4 extended point count (u64)
972    buf.extend_from_slice(&n.to_le_bytes());
973    // Extended point counts by return (15 × u64)
974    buf.extend_from_slice(&[0u8; 120]);
975
976    // Sanity-check: buf.len() must equal header_size at this point.
977    assert_eq!(buf.len(), header_size as usize, "header length mismatch");
978
979    // ── Point records (Point Format 0) ────────────────────────────────────────
980    for &[x, y, z] in points {
981        let raw_x = ((x) / scale).round() as i32;
982        let raw_y = ((y) / scale).round() as i32;
983        let raw_z = ((z) / scale).round() as i32;
984        buf.extend_from_slice(&raw_x.to_le_bytes());
985        buf.extend_from_slice(&raw_y.to_le_bytes());
986        buf.extend_from_slice(&raw_z.to_le_bytes());
987        buf.extend_from_slice(&0u16.to_le_bytes()); // intensity
988        buf.push(0u8); // return flags
989        buf.push(0u8); // classification
990        buf.push(0u8); // scan angle (i8 → u8 cast fine for 0)
991        buf.push(0u8); // user data
992        buf.extend_from_slice(&0u16.to_le_bytes()); // point source ID
993    }
994
995    buf
996}
997
998// ---------------------------------------------------------------------------
999// SatelliteImage
1000// ---------------------------------------------------------------------------
1001
1002/// Multi-spectral satellite image with named bands.
1003#[derive(Debug, Clone)]
1004pub struct SatelliteImage {
1005    /// Flattened (row-major) pixel values per band.
1006    pub bands: Vec<Vec<f64>>,
1007    /// Human-readable names for each band (e.g. `"red"`, `"nir"`).
1008    pub band_names: Vec<String>,
1009}
1010
1011impl SatelliteImage {
1012    /// Construct a `SatelliteImage` from per-band pixel data and band names.
1013    pub fn new(bands: Vec<Vec<f64>>, band_names: Vec<String>) -> Self {
1014        Self { bands, band_names }
1015    }
1016
1017    /// Return the index of the band with the given name, if present.
1018    pub fn band_index(&self, name: &str) -> Option<usize> {
1019        self.band_names.iter().position(|n| n == name)
1020    }
1021
1022    /// Compute the Normalized Difference Vegetation Index (NDVI).
1023    ///
1024    /// Requires bands named `"nir"` and `"red"`.  Returns a vector of per-pixel
1025    /// NDVI values in `[-1, 1]`.
1026    pub fn ndvi(&self) -> Vec<f64> {
1027        let nir_idx = self.band_index("nir").unwrap_or(0);
1028        let red_idx = self.band_index("red").unwrap_or(1);
1029        if self.bands.len() <= nir_idx.max(red_idx) {
1030            return vec![];
1031        }
1032        let nir = &self.bands[nir_idx];
1033        let red = &self.bands[red_idx];
1034        nir.iter()
1035            .zip(red.iter())
1036            .map(|(&n, &r)| {
1037                let denom = n + r;
1038                if denom.abs() < 1e-10 {
1039                    0.0
1040                } else {
1041                    (n - r) / denom
1042                }
1043            })
1044            .collect()
1045    }
1046
1047    /// Compute the Normalized Difference Water Index (NDWI).
1048    ///
1049    /// Requires bands named `"green"` and `"nir"`.  Returns per-pixel values
1050    /// in `[-1, 1]`.
1051    pub fn ndwi(&self) -> Vec<f64> {
1052        let green_idx = self.band_index("green").unwrap_or(0);
1053        let nir_idx = self.band_index("nir").unwrap_or(1);
1054        if self.bands.len() <= green_idx.max(nir_idx) {
1055            return vec![];
1056        }
1057        let green = &self.bands[green_idx];
1058        let nir = &self.bands[nir_idx];
1059        green
1060            .iter()
1061            .zip(nir.iter())
1062            .map(|(&g, &n)| {
1063                let denom = g + n;
1064                if denom.abs() < 1e-10 {
1065                    0.0
1066                } else {
1067                    (g - n) / denom
1068                }
1069            })
1070            .collect()
1071    }
1072
1073    /// Produce an RGB false-colour composite by selecting bands `r`, `g`, `b`
1074    /// (0-indexed) and normalising each channel to `[0, 1]`.
1075    pub fn false_color_composite(&self, r: usize, g: usize, b: usize) -> Vec<[f64; 3]> {
1076        if self.bands.len() <= r.max(g).max(b) {
1077            return vec![];
1078        }
1079        let normalize = |band: &Vec<f64>| -> Vec<f64> {
1080            let mn = band.iter().cloned().fold(f64::INFINITY, f64::min);
1081            let mx = band.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1082            let range = mx - mn;
1083            if range < 1e-10 {
1084                vec![0.0; band.len()]
1085            } else {
1086                band.iter().map(|&v| (v - mn) / range).collect()
1087            }
1088        };
1089        let rb = normalize(&self.bands[r]);
1090        let gb = normalize(&self.bands[g]);
1091        let bb = normalize(&self.bands[b]);
1092        rb.iter()
1093            .zip(gb.iter())
1094            .zip(bb.iter())
1095            .map(|((&rv, &gv), &bv)| [rv, gv, bv])
1096            .collect()
1097    }
1098}
1099
1100// ---------------------------------------------------------------------------
1101// Tests
1102// ---------------------------------------------------------------------------
1103
1104#[cfg(test)]
1105mod tests {
1106    use super::*;
1107
1108    fn sample_raster() -> RasterData {
1109        // 5×5 DEM with a simple gradient
1110        let data: Vec<Vec<f64>> = (0..5)
1111            .map(|r| (0..5).map(|c| (r * 5 + c) as f64 * 10.0).collect())
1112            .collect();
1113        RasterData::new(data, -9999.0)
1114    }
1115
1116    fn sample_dem() -> Dem {
1117        Dem::new(sample_raster(), 30.0)
1118    }
1119
1120    // --- GeoTiffReader ---
1121
1122    #[test]
1123    fn test_geotiff_pixel_to_world_origin() {
1124        let t = [100.0, 0.5, 0.0, 50.0, 0.0, -0.5];
1125        let reader = GeoTiffReader::new(10, 10, 1, t, vec![vec![1.0; 100]]);
1126        let [lon, lat] = reader.pixel_to_world(0, 0);
1127        assert!((lon - 100.0).abs() < 1e-9);
1128        assert!((lat - 50.0).abs() < 1e-9);
1129    }
1130
1131    #[test]
1132    fn test_geotiff_pixel_to_world_offset() {
1133        let t = [0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
1134        let reader = GeoTiffReader::new(4, 4, 1, t, vec![vec![0.0; 16]]);
1135        let [lon, lat] = reader.pixel_to_world(2, 3);
1136        assert!((lon - 2.0).abs() < 1e-9);
1137        assert!((lat - 3.0).abs() < 1e-9);
1138    }
1139
1140    #[test]
1141    fn test_geotiff_world_to_pixel_roundtrip() {
1142        let t = [10.0, 0.1, 0.0, 20.0, 0.0, -0.1];
1143        let data = vec![vec![5.0; 100]];
1144        let reader = GeoTiffReader::new(10, 10, 1, t, data);
1145        let (px, py) = reader.world_to_pixel(10.5, 19.5);
1146        assert_eq!(px, 5);
1147        assert_eq!(py, 5);
1148    }
1149
1150    #[test]
1151    fn test_geotiff_elevation_at() {
1152        let t = [0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
1153        let band: Vec<f64> = (0..16).map(|i| i as f64).collect();
1154        let reader = GeoTiffReader::new(4, 4, 1, t, vec![band]);
1155        // pixel (0,0) → index 0 → value 0.0
1156        let elev = reader.elevation_at(0.0, 0.0);
1157        assert!((elev - 0.0).abs() < 1e-9);
1158    }
1159
1160    #[test]
1161    fn test_geotiff_bounding_box_size() {
1162        let t = [10.0, 1.0, 0.0, 50.0, 0.0, -1.0];
1163        let reader = GeoTiffReader::new(5, 5, 1, t, vec![vec![0.0; 25]]);
1164        let bb = reader.bounding_box();
1165        assert!(bb[2] > bb[0]);
1166    }
1167
1168    #[test]
1169    fn test_geotiff_read_band_empty_when_out_of_range() {
1170        let reader = GeoTiffReader::new(2, 2, 1, [0.0; 6], vec![vec![1.0; 4]]);
1171        assert!(reader.read_band(5).is_empty());
1172    }
1173
1174    // --- RasterData ---
1175
1176    #[test]
1177    fn test_raster_statistics_basic() {
1178        let r = sample_raster();
1179        let (mn, mx, mean, _std) = r.statistics();
1180        assert!((mn - 0.0).abs() < 1e-9);
1181        assert!((mx - 240.0).abs() < 1e-9);
1182        assert!(mean > 0.0);
1183    }
1184
1185    #[test]
1186    fn test_raster_statistics_all_nodata() {
1187        let r = RasterData::new(vec![vec![-9999.0; 3]; 3], -9999.0);
1188        let (mn, mx, mean, std) = r.statistics();
1189        assert_eq!((mn, mx, mean, std), (0.0, 0.0, 0.0, 0.0));
1190    }
1191
1192    #[test]
1193    fn test_raster_clip_to_bbox_dimensions() {
1194        let r = sample_raster();
1195        let clipped = r.clip_to_bbox([1.0, 1.0, 4.0, 4.0]);
1196        assert_eq!(clipped.width, 3);
1197        assert_eq!(clipped.height, 3);
1198    }
1199
1200    #[test]
1201    fn test_raster_resample_down() {
1202        let r = sample_raster();
1203        let small = r.resample(0.5);
1204        assert!(small.width <= r.width);
1205        assert!(small.height <= r.height);
1206    }
1207
1208    #[test]
1209    fn test_raster_resample_up() {
1210        let r = sample_raster();
1211        let big = r.resample(2.0);
1212        assert!(big.width >= r.width);
1213        assert!(big.height >= r.height);
1214    }
1215
1216    #[test]
1217    fn test_raster_slope_border_zero() {
1218        let r = sample_raster();
1219        let s = r.slope();
1220        // Border pixels not computed (remain 0)
1221        assert!((s[0][0] - 0.0).abs() < 1e-9);
1222    }
1223
1224    #[test]
1225    fn test_raster_slope_interior_positive() {
1226        let r = sample_raster();
1227        let s = r.slope();
1228        assert!(s[2][2] >= 0.0);
1229    }
1230
1231    #[test]
1232    fn test_raster_aspect_range() {
1233        let r = sample_raster();
1234        let a = r.aspect();
1235        for row in &a {
1236            for &v in row {
1237                assert!(v >= 0.0 && v < 360.0 + 1e-9, "aspect out of range: {v}");
1238            }
1239        }
1240    }
1241
1242    #[test]
1243    fn test_raster_hillshade_range() {
1244        let r = sample_raster();
1245        let hs = r.hillshade(315.0, 45.0);
1246        for row in &hs {
1247            for &v in row {
1248                assert!(v >= 0.0 && v <= 1.0 + 1e-9, "hillshade out of [0,1]: {v}");
1249            }
1250        }
1251    }
1252
1253    // --- Dem ---
1254
1255    #[test]
1256    fn test_dem_flow_direction_shape() {
1257        let d = sample_dem();
1258        let fd = d.flow_direction();
1259        assert_eq!(fd.len(), 5);
1260        assert_eq!(fd[0].len(), 5);
1261    }
1262
1263    #[test]
1264    fn test_dem_flow_accumulation_shape() {
1265        let d = sample_dem();
1266        let fa = d.flow_accumulation();
1267        assert_eq!(fa.len(), 5);
1268        assert_eq!(fa[0].len(), 5);
1269    }
1270
1271    #[test]
1272    fn test_dem_flow_accumulation_positive() {
1273        let d = sample_dem();
1274        let fa = d.flow_accumulation();
1275        for row in &fa {
1276            for &v in row {
1277                assert!(v >= 1.0, "flow accumulation must be ≥ 1");
1278            }
1279        }
1280    }
1281
1282    #[test]
1283    fn test_dem_watershed_contains_outlet() {
1284        let d = sample_dem();
1285        let ws = d.watershed_delineation((4, 4));
1286        assert!(ws.contains(&(4, 4)));
1287    }
1288
1289    #[test]
1290    fn test_dem_stream_network_threshold_high() {
1291        let d = sample_dem();
1292        // Very high threshold → no streams
1293        let segs = d.extract_stream_network(1e12);
1294        assert!(segs.is_empty());
1295    }
1296
1297    #[test]
1298    fn test_dem_stream_network_threshold_low() {
1299        let d = sample_dem();
1300        // Low threshold → all cells are streams
1301        let segs = d.extract_stream_network(0.0);
1302        assert!(!segs.is_empty());
1303    }
1304
1305    // --- PointCloudIO ---
1306
1307    #[test]
1308    fn test_point_cloud_write_read_roundtrip() {
1309        let pts = vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
1310        let s = PointCloudIO::write_xyz(&pts);
1311        let back = PointCloudIO::read_xyz(&s);
1312        assert_eq!(back.len(), 2);
1313        assert!((back[0][0] - 1.0).abs() < 1e-9);
1314        assert!((back[1][2] - 6.0).abs() < 1e-9);
1315    }
1316
1317    #[test]
1318    fn test_point_cloud_bounding_box_empty() {
1319        let bb = PointCloudIO::bounding_box(&[]);
1320        assert_eq!(bb, [0.0; 6]);
1321    }
1322
1323    #[test]
1324    fn test_point_cloud_bounding_box_single() {
1325        let bb = PointCloudIO::bounding_box(&[[3.0, 1.0, -2.0]]);
1326        assert!((bb[0] - 3.0).abs() < 1e-9);
1327        assert!((bb[3] - 3.0).abs() < 1e-9);
1328    }
1329
1330    #[test]
1331    fn test_point_cloud_bounding_box_multiple() {
1332        let pts = vec![[0.0, 0.0, 0.0], [1.0, 2.0, 3.0], [-1.0, 5.0, -2.0]];
1333        let bb = PointCloudIO::bounding_box(&pts);
1334        assert!((bb[0] - (-1.0)).abs() < 1e-9); // min_x
1335        assert!((bb[4] - 5.0).abs() < 1e-9); // max_y
1336    }
1337
1338    #[test]
1339    fn test_point_cloud_voxelize_reduces_count() {
1340        let pts: Vec<[f64; 3]> = (0..100)
1341            .map(|i| [(i % 10) as f64 * 0.1, (i / 10) as f64 * 0.1, 0.0])
1342            .collect();
1343        let vox = PointCloudIO::voxelize(&pts, 0.5);
1344        assert!(vox.len() < pts.len());
1345    }
1346
1347    #[test]
1348    fn test_point_cloud_las_stub_comments_skipped() {
1349        let content = "# header\n1 2 3\n# comment\n4 5 6\n";
1350        let pts = PointCloudIO::read_las_stub(content);
1351        assert_eq!(pts.len(), 2);
1352    }
1353
1354    // --- SatelliteImage ---
1355
1356    fn make_image() -> SatelliteImage {
1357        let red: Vec<f64> = (0..4).map(|i| i as f64 * 0.1).collect();
1358        let nir: Vec<f64> = (0..4).map(|i| i as f64 * 0.2 + 0.1).collect();
1359        let green: Vec<f64> = (0..4).map(|i| i as f64 * 0.15).collect();
1360        SatelliteImage::new(
1361            vec![red, nir, green],
1362            vec!["red".into(), "nir".into(), "green".into()],
1363        )
1364    }
1365
1366    #[test]
1367    fn test_satellite_ndvi_length() {
1368        let img = make_image();
1369        assert_eq!(img.ndvi().len(), 4);
1370    }
1371
1372    #[test]
1373    fn test_satellite_ndvi_range() {
1374        let img = make_image();
1375        for v in img.ndvi() {
1376            assert!(
1377                v >= -1.0 - 1e-9 && v <= 1.0 + 1e-9,
1378                "NDVI out of range: {v}"
1379            );
1380        }
1381    }
1382
1383    #[test]
1384    fn test_satellite_ndwi_length() {
1385        let img = make_image();
1386        assert_eq!(img.ndwi().len(), 4);
1387    }
1388
1389    #[test]
1390    fn test_satellite_false_color_length() {
1391        let img = make_image();
1392        let fc = img.false_color_composite(0, 2, 1);
1393        assert_eq!(fc.len(), 4);
1394    }
1395
1396    #[test]
1397    fn test_satellite_false_color_range() {
1398        let img = make_image();
1399        for px in img.false_color_composite(0, 2, 1) {
1400            for &ch in &px {
1401                assert!(
1402                    ch >= 0.0 - 1e-9 && ch <= 1.0 + 1e-9,
1403                    "channel out of [0,1]: {ch}"
1404                );
1405            }
1406        }
1407    }
1408
1409    #[test]
1410    fn test_satellite_band_index_found() {
1411        let img = make_image();
1412        assert_eq!(img.band_index("nir"), Some(1));
1413    }
1414
1415    #[test]
1416    fn test_satellite_band_index_not_found() {
1417        let img = make_image();
1418        assert_eq!(img.band_index("swir"), None);
1419    }
1420
1421    // ── G5: LAS binary reader tests ───────────────────────────────────────────
1422
1423    #[test]
1424    fn test_read_las_bad_signature() {
1425        let data = b"NOTL\x00\x00\x00\x00";
1426        assert!(read_las_binary(data).is_err());
1427    }
1428
1429    #[test]
1430    fn test_read_las_too_short() {
1431        let data = vec![0u8; 100];
1432        assert!(read_las_binary(&data).is_err());
1433    }
1434
1435    #[test]
1436    fn test_las_roundtrip_empty() {
1437        let data = write_las_binary_minimal(&[], 0.001);
1438        let cloud = read_las_binary(&data).expect("should parse empty LAS");
1439        assert_eq!(cloud.points.len(), 0);
1440        assert_eq!(cloud.header.version_major, 1);
1441        assert_eq!(cloud.header.version_minor, 4);
1442    }
1443
1444    #[test]
1445    fn test_las_roundtrip_single_point() {
1446        let pts = vec![[1.234, 5.678, -0.5]];
1447        let data = write_las_binary_minimal(&pts, 0.001);
1448        let cloud = read_las_binary(&data).expect("should parse single-point LAS");
1449        assert_eq!(cloud.points.len(), 1);
1450        assert!((cloud.points[0].x - 1.234).abs() < 0.002, "x mismatch");
1451        assert!((cloud.points[0].y - 5.678).abs() < 0.002, "y mismatch");
1452        assert!((cloud.points[0].z - (-0.5)).abs() < 0.002, "z mismatch");
1453    }
1454
1455    #[test]
1456    fn test_las_roundtrip_multiple_points() {
1457        let pts: Vec<[f64; 3]> = (0..10)
1458            .map(|i| [i as f64 * 0.5, i as f64 * 0.25, i as f64 * 0.1])
1459            .collect();
1460        let data = write_las_binary_minimal(&pts, 0.001);
1461        let cloud = read_las_binary(&data).expect("should parse multi-point LAS");
1462        assert_eq!(cloud.points.len(), 10);
1463        for (i, pt) in cloud.points.iter().enumerate() {
1464            let expected_x = i as f64 * 0.5;
1465            assert!(
1466                (pt.x - expected_x).abs() < 0.002,
1467                "x mismatch at point {i}: expected {expected_x}, got {}",
1468                pt.x
1469            );
1470        }
1471    }
1472}