Ten-module atlas package implementing the D-194–D-218 district generation stack: heightmap loader, BodyWorldState LRU cache, D8 drainage routing, background generation queue, five-phase attractor-matching, three-component district mix, block irregularity, tile condition thresholds, and the Phase 1 skeleton generator that wires them into DistrictSkeleton. Closes #916 #917 #918 #919 #920 #922 #923 #924 #899. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
202 lines
6.8 KiB
Rust
202 lines
6.8 KiB
Rust
//! 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<f32>` 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<f32>,
|
||
/// 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<f32> {
|
||
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<u8>` 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<BodyHeightmap, HeightmapLoadError> {
|
||
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<u8> = 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<f32> = (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 { .. }));
|
||
}
|
||
}
|