feat: complete file-based heightmap migration — tEXt sea_level, client relief, drop BLOB (#963)
- heightmap.rs: read sea_level from the PNG tEXt chunk (bake writes it), default-fallback param; new test reads_sea_level_from_text_chunk. - client atlas_viewer.gd: load reliefmap.png (color display) instead of heightmap.png (now 16-bit grayscale elevation, cascade-only). - drop atlas_body_heightmaps: removed from systems-schema.sql; DROP TABLE in import_economics MIGRATION_SQL (the PNG is the store now). - D-202 amendment: implementation-status note (consumer + producer done), resolving the review's 'reads done but producer pending' point. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -262,12 +262,15 @@ func _load_heightmap() -> void:
|
||||
var ref: Variant = _body.get("terrain_reference")
|
||||
if ref == null or str(ref).is_empty():
|
||||
return
|
||||
var path: String = str(ref)
|
||||
# terrain_reference points at <body>/heightmap.png — now the canonical
|
||||
# 16-bit GRAYSCALE elevation (D-202 amended, #963). For display we load the
|
||||
# color reliefmap.png that sits beside it.
|
||||
var path: String = str(ref).get_base_dir() + "/reliefmap.png"
|
||||
if not path.begins_with("/"):
|
||||
var project_root: String = ProjectSettings.globalize_path("res://").get_base_dir().get_base_dir()
|
||||
path = project_root + "/" + path
|
||||
if not FileAccess.file_exists(path):
|
||||
push_warning("AtlasViewer: terrain_reference not found at %s" % path)
|
||||
push_warning("AtlasViewer: reliefmap not found at %s" % path)
|
||||
return
|
||||
var img := Image.load_from_file(path)
|
||||
if img != null:
|
||||
|
||||
@@ -927,6 +927,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
- **Multi-resolution:** the stored heightmap is high-res for the lower layers (region/block/tile sample local detail); **Layer 1** (continental drainage/basins/mountain-ranges) calls `BodyHeightmap::downsample` to the `GRID_W×GRID_H = 512×256` working resolution first, decoupling continental compute cost (~45ms) from stored resolution.
|
||||
- **Rust loader:** `heightmap.rs::load_heightmap_png` reads the 16-bit grayscale PNG (via the `png` crate), normalizes to f32 [0,1]; rejects RGB (a reliefmap can't be misread as elevation). `sea_level` becomes body metadata carried alongside (not in the PNG).
|
||||
- **`atlas_body_heightmaps` is dropped**; `import_heightmaps.py` writes the PNG file instead of a DB row. The bake runs in the content pipeline (numpy/scipy) once; the runtime cascade is pure Rust loading the file.
|
||||
- **Implementation status (#963):** Consumer done — `heightmap.rs::load_heightmap_png` reads the 16-bit grayscale PNG + `sea_level` tEXt chunk, rejects RGB, downsamples for Layer 1. Producer done — `import_heightmaps.py` is the bake (rename legacy `heightmap.png`→`reliefmap.png` for all bodies incl. Sol; for non-Sol inhabited bodies write a fresh clean `reliefmap.png` + 16-bit `heightmap.png` from `simulate()` at the bumped 1024×512 grid). `atlas_body_heightmaps` dropped via MIGRATION_SQL + removed from `systems-schema.sql`. Godot client (`atlas_viewer.gd`) loads `reliefmap.png` for display. Sim determinism guarded by `test_sim_determinism.py`.
|
||||
- **Rationale:** Storing heightmaps in `systems.db` keeps the DB as the single source of truth for all generation inputs, avoids a separate file-fetching path in the Rust server, and allows the pre-push hook (asset pipeline rules) to detect stale heightmap data. The float32 LE layout matches what NumPy and PIL produce natively, minimizing conversion overhead in the Python pipeline. *(Superseded by the #963 amendment above — the file-based model won out because the DB-as-single-source goal conflicts with binary-merge-conflict avoidance and DB size; a per-body committed PNG is itself a queryable, diffable-by-render asset.)*
|
||||
- **Ticket:** #901 (schema), #906 (import), #916 (Rust loader)
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
|
||||
@@ -462,17 +462,10 @@ CREATE INDEX IF NOT EXISTS idx_atlas_pois_kind ON atlas_pois(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_rivers_body ON atlas_rivers(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_oceans_body ON atlas_oceans(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_mountain_ranges_body ON atlas_mountain_ranges(body_id);
|
||||
-- Heightmap BLOB storage — float32 LE, row-major (D-202, #901)
|
||||
-- Only inhabited bodies receive rows at build time; uninhabited bodies are
|
||||
-- generated on-demand by the runtime-background tier.
|
||||
CREATE TABLE IF NOT EXISTS atlas_body_heightmaps (
|
||||
body_id TEXT PRIMARY KEY REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
width INTEGER NOT NULL DEFAULT 512,
|
||||
height INTEGER NOT NULL DEFAULT 256,
|
||||
data BLOB NOT NULL, -- float32 LE, row-major, width×height values
|
||||
sea_level REAL NOT NULL DEFAULT 0.0,
|
||||
imported_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
-- Heightmap BLOB storage REMOVED (D-202 amended, #963): canonical elevation is
|
||||
-- now a per-body 16-bit grayscale heightmap.png file (sea_level in a tEXt
|
||||
-- chunk), not a systems.db BLOB. The atlas_body_heightmaps table is dropped via
|
||||
-- MIGRATION_SQL in import_economics.py.
|
||||
|
||||
-- City name reservations — replaces authored city positions in markers.json (D-207, #902)
|
||||
-- Position is generated by the city placement algorithm; name is authored or LLM-generated.
|
||||
@@ -521,7 +514,6 @@ CREATE TABLE IF NOT EXISTS atlas_city_positions (
|
||||
score REAL NOT NULL -- match quality [0.0, 1.0]
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_body_heightmaps_body ON atlas_body_heightmaps(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_names_body ON atlas_city_names(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_names_kind ON atlas_city_names(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_names_corp ON atlas_city_names(corp_id);
|
||||
|
||||
@@ -110,23 +110,32 @@ pub enum HeightmapLoadError {
|
||||
pub fn load_heightmap_png(
|
||||
path: &Path,
|
||||
body_id: &str,
|
||||
sea_level: f32,
|
||||
default_sea_level: f32,
|
||||
) -> Result<BodyHeightmap, HeightmapLoadError> {
|
||||
let file = File::open(path)?;
|
||||
load_heightmap_reader(file, body_id, sea_level)
|
||||
load_heightmap_reader(file, body_id, default_sea_level)
|
||||
}
|
||||
|
||||
/// Decode a heightmap PNG from any reader. 16-bit grayscale is the canonical
|
||||
/// format; 8-bit grayscale is accepted (coarse — viewable/test only).
|
||||
///
|
||||
/// `sea_level` is read from the PNG's `sea_level` tEXt chunk (written by the
|
||||
/// bake); `default_sea_level` is the fallback when the chunk is absent.
|
||||
pub fn load_heightmap_reader<R: Read>(
|
||||
reader: R,
|
||||
body_id: &str,
|
||||
sea_level: f32,
|
||||
default_sea_level: f32,
|
||||
) -> Result<BodyHeightmap, HeightmapLoadError> {
|
||||
let mut png_reader = png::Decoder::new(reader).read_info()?;
|
||||
let (width, height, bit_depth, color_type) = {
|
||||
let (width, height, bit_depth, color_type, sea_level) = {
|
||||
let info = png_reader.info();
|
||||
(info.width, info.height, info.bit_depth, info.color_type)
|
||||
let sea_level = info
|
||||
.uncompressed_latin1_text
|
||||
.iter()
|
||||
.find(|c| c.keyword == "sea_level")
|
||||
.and_then(|c| c.text.trim().parse::<f32>().ok())
|
||||
.unwrap_or(default_sea_level);
|
||||
(info.width, info.height, info.bit_depth, info.color_type, sea_level)
|
||||
};
|
||||
let mut buf = vec![0u8; png_reader.output_buffer_size()];
|
||||
let frame = png_reader.next_frame(&mut buf)?;
|
||||
@@ -230,4 +239,26 @@ mod tests {
|
||||
let err = load_heightmap_reader(out.as_slice(), "T", 0.3).unwrap_err();
|
||||
assert!(matches!(err, HeightmapLoadError::UnsupportedFormat { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_sea_level_from_text_chunk() {
|
||||
// The bake writes sea_level as a tEXt chunk; the loader must prefer it
|
||||
// over the supplied default.
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut out, 2, 1);
|
||||
enc.set_color(png::ColorType::Grayscale);
|
||||
enc.set_depth(png::BitDepth::Sixteen);
|
||||
enc.add_text_chunk("sea_level".to_string(), "0.42".to_string())
|
||||
.unwrap();
|
||||
let mut w = enc.write_header().unwrap();
|
||||
w.write_image_data(&[0u8; 4]).unwrap(); // 2×1 × 2 bytes
|
||||
}
|
||||
let hm = load_heightmap_reader(out.as_slice(), "T", 0.1).unwrap();
|
||||
assert!(
|
||||
(hm.sea_level - 0.42).abs() < 1e-6,
|
||||
"sea_level must come from the tEXt chunk, got {}",
|
||||
hm.sea_level
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,16 +297,10 @@ DELETE FROM meta WHERE generator_name = 'generate_brands';
|
||||
-- (T6) would reject any committed DB that still carries the old row.
|
||||
DELETE FROM meta WHERE generator_name = 'generate_atlas';
|
||||
|
||||
-- Heightmap BLOB storage (D-202, #901)
|
||||
CREATE TABLE IF NOT EXISTS atlas_body_heightmaps (
|
||||
body_id TEXT PRIMARY KEY REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
width INTEGER NOT NULL DEFAULT 512,
|
||||
height INTEGER NOT NULL DEFAULT 256,
|
||||
data BLOB NOT NULL,
|
||||
sea_level REAL NOT NULL DEFAULT 0.0,
|
||||
imported_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_body_heightmaps_body ON atlas_body_heightmaps(body_id);
|
||||
-- Drop the retired heightmap BLOB table (D-202 amended, #963): canonical
|
||||
-- elevation is now a per-body 16-bit grayscale heightmap.png file, not a DB
|
||||
-- BLOB. The Rust loader reads the PNG; nothing reads this table anymore.
|
||||
DROP TABLE IF EXISTS atlas_body_heightmaps;
|
||||
|
||||
-- City name reservations (D-207, #902)
|
||||
CREATE TABLE IF NOT EXISTS atlas_city_names (
|
||||
|
||||
Reference in New Issue
Block a user