//! Heightmap BLOB loader — reads float32 LE elevation grids from systems.db. //! //! Implements the Rust side of D-202. The Python pipeline stores each body's //! elevation grid as a contiguous float32 little-endian BLOB in //! `atlas_body_heightmaps.data`. This module loads that BLOB via `rusqlite` //! and reinterprets the bytes into a `Vec` using `bytemuck`. //! //! Values are normalized elevation in [0.0, 1.0]. `sea_level` is the fraction //! below which terrain is underwater (0.0 = no ocean). //! //! Canonical grid size: 512 × 256 (GRID_W × GRID_H), row-major. use rusqlite::{params, Connection}; use thiserror::Error; /// Canonical grid dimensions matching the Python pipeline (generate_atlas.py). pub const GRID_W: u32 = 512; pub const GRID_H: u32 = 256; /// A loaded heightmap for one planetary body. #[derive(Debug, Clone)] pub struct BodyHeightmap { pub body_id: String, pub width: u32, pub height: u32, /// Row-major elevation values, normalized to [0.0, 1.0]. pub data: Vec, /// Elevation fraction below which terrain is ocean/sea. pub sea_level: f32, } impl BodyHeightmap { /// Returns the elevation at (row, col), or `None` if out of bounds. #[inline] pub fn get(&self, row: u32, col: u32) -> Option { if row < self.height && col < self.width { Some(self.data[(row * self.width + col) as usize]) } else { None } } /// Returns `true` if the cell at (row, col) is land (above sea level). #[inline] pub fn is_land(&self, row: u32, col: u32) -> bool { self.get(row, col).map_or(false, |e| e >= self.sea_level) } } #[derive(Debug, Error)] pub enum HeightmapLoadError { #[error("no heightmap row for body '{0}'")] NotFound(String), #[error("BLOB size {actual} does not match declared grid {w}×{h}×4 = {expected}")] BlobSizeMismatch { actual: usize, w: u32, h: u32, expected: usize, }, #[error("SQLite error: {0}")] Sql(#[from] rusqlite::Error), } /// Load the heightmap for `body_id` from the open `conn`. /// /// The BLOB is reinterpreted in-place via `bytemuck::cast_slice` — no copy /// beyond the initial `Vec` read from SQLite. On little-endian hosts /// (all current targets) this is a zero-cost reinterpret. On big-endian hosts /// the bytes are already stored LE, so each f32 would be byte-swapped; this /// function does not perform that swap — big-endian support is deferred. pub fn load_heightmap( conn: &Connection, body_id: &str, ) -> Result { let result = conn.query_row( "SELECT width, height, data, sea_level \ FROM atlas_body_heightmaps WHERE body_id = ?1", params![body_id], |row| { let width: u32 = row.get(0)?; let height: u32 = row.get(1)?; let blob: Vec = row.get(2)?; let sea_level: f64 = row.get(3)?; Ok((width, height, blob, sea_level as f32)) }, ); match result { Err(rusqlite::Error::QueryReturnedNoRows) => { Err(HeightmapLoadError::NotFound(body_id.to_string())) } Err(e) => Err(HeightmapLoadError::Sql(e)), Ok((width, height, blob, sea_level)) => { let expected = (width * height * 4) as usize; if blob.len() != expected { return Err(HeightmapLoadError::BlobSizeMismatch { actual: blob.len(), w: width, h: height, expected, }); } // Reinterpret the LE bytes as f32 values. bytemuck::cast_slice // is safe here: we verified the length is a multiple of 4, and // f32 has no invalid bit patterns. let floats: &[f32] = bytemuck::cast_slice(&blob); let data = floats.to_vec(); Ok(BodyHeightmap { body_id: body_id.to_string(), width, height, data, sea_level, }) } } } #[cfg(test)] mod tests { use super::*; use rusqlite::Connection; fn make_test_db() -> Connection { let conn = Connection::open_in_memory().unwrap(); conn.execute_batch( "CREATE TABLE atlas_body_heightmaps ( body_id TEXT PRIMARY KEY, width INTEGER NOT NULL, height INTEGER NOT NULL, data BLOB NOT NULL, sea_level REAL NOT NULL DEFAULT 0.0, imported_at TEXT NOT NULL DEFAULT (datetime('now')) );", ) .unwrap(); conn } fn insert_heightmap(conn: &Connection, body_id: &str, w: u32, h: u32, sea_level: f32) { let floats: Vec = (0..(w * h)) .map(|i| i as f32 / (w * h) as f32) .collect(); let bytes: &[u8] = bytemuck::cast_slice(&floats); conn.execute( "INSERT INTO atlas_body_heightmaps (body_id, width, height, data, sea_level) VALUES (?1, ?2, ?3, ?4, ?5)", params![body_id, w, h, bytes, sea_level], ) .unwrap(); } #[test] fn round_trip_canonical_size() { let conn = make_test_db(); insert_heightmap(&conn, "TestBody", GRID_W, GRID_H, 0.3); let hm = load_heightmap(&conn, "TestBody").unwrap(); assert_eq!(hm.width, GRID_W); assert_eq!(hm.height, GRID_H); assert_eq!(hm.data.len(), (GRID_W * GRID_H) as usize); assert!((hm.sea_level - 0.3).abs() < 1e-6); // First cell is 0.0, last approaches 1.0 assert_eq!(hm.data[0], 0.0); assert!(hm.data.last().copied().unwrap() < 1.0); } #[test] fn get_and_is_land() { let conn = make_test_db(); insert_heightmap(&conn, "LandBody", 4, 2, 0.5); let hm = load_heightmap(&conn, "LandBody").unwrap(); // First cell (index 0) = 0.0 / 8 = 0.0 — below sea level assert!(!hm.is_land(0, 0)); // Last cell (index 7) = 7.0 / 8 = 0.875 — above sea level assert!(hm.is_land(1, 3)); // Out-of-bounds returns false assert!(!hm.is_land(99, 99)); } #[test] fn not_found_error() { let conn = make_test_db(); let err = load_heightmap(&conn, "Ghost").unwrap_err(); assert!(matches!(err, HeightmapLoadError::NotFound(_))); } #[test] fn blob_size_mismatch_error() { let conn = make_test_db(); // Insert a truncated BLOB conn.execute( "INSERT INTO atlas_body_heightmaps (body_id, width, height, data, sea_level) VALUES ('BadBlob', 4, 4, X'DEADBEEF', 0.0)", [], ) .unwrap(); let err = load_heightmap(&conn, "BadBlob").unwrap_err(); assert!(matches!(err, HeightmapLoadError::BlobSizeMismatch { .. })); } }