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:
2026-05-23 08:23:23 +02:00
co-authored by Claude Opus 4.7
parent 3b2cd6914e
commit b71f339996
5 changed files with 50 additions and 29 deletions
+36 -5
View File
@@ -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
);
}
}