feat(simulation): district-window zoom-ladder renderer — aliveness_probe --render/--window/--anchor (T-1123)
Renders true-2km district maps (derive_district per cell, NOT the coarse eager grid) as PNGs: morphology (17-zone palette transliterated from atlas_marker_overlay.gd, sync-commented), elev/temp/moisture/vegetation panels, anchor crosshair, km-honest filenames + manifest. believability.rs gains cascade_snapshot_for_body (cascade_for_body now a thin wrapper); probe re-derives the D-203-freed TerrainAnalysis via run_layer1 only under --render. Determinism proven: 5/5 panels byte-identical across passes AND cross-process (md5s reproduced in a fresh run). ~8us/district debug. First-ever look at the district layer produced the T-1125/T-1126/T-1127 findings (D-227 seam ends at ~500km; ocean-blind vegetation; unrendered glaciation). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -45,9 +45,9 @@ use serde::{Deserialize, Serialize};
|
|||||||
use crate::atlas::attractor_matching::CityRecord;
|
use crate::atlas::attractor_matching::CityRecord;
|
||||||
use crate::atlas::body_params_reader::BodyParamsReader;
|
use crate::atlas::body_params_reader::BodyParamsReader;
|
||||||
use crate::atlas::body_world_state::BodyWorldState;
|
use crate::atlas::body_world_state::BodyWorldState;
|
||||||
use crate::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer};
|
use crate::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer, CascadeSnapshot};
|
||||||
use crate::atlas::chunk_context::derive_chunk_context;
|
use crate::atlas::chunk_context::derive_chunk_context;
|
||||||
use crate::atlas::district_profile::DistrictProfile;
|
use crate::atlas::district_profile::{BodyParams, DistrictProfile};
|
||||||
use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W};
|
use crate::atlas::heightmap::{load_heightmap_png, GRID_H, GRID_W};
|
||||||
use crate::atlas::scale::{ChunkPos, DistrictPos, CHUNKS_PER_DISTRICT, CHUNK_M};
|
use crate::atlas::scale::{ChunkPos, DistrictPos, CHUNKS_PER_DISTRICT, CHUNK_M};
|
||||||
use crate::atlas::voxel::{derive_voxel_column, Vegetation, Water};
|
use crate::atlas::voxel::{derive_voxel_column, Vegetation, Water};
|
||||||
@@ -508,9 +508,26 @@ pub fn seed_to_u64(seed: &str) -> u64 {
|
|||||||
///
|
///
|
||||||
/// `Err` if `systems.db` or the body's `heightmap.png` cannot be found, or the body
|
/// `Err` if `systems.db` or the body's `heightmap.png` cannot be found, or the body
|
||||||
/// has no params — callers (the regression harness) may *skip* on that rather than
|
/// has no params — callers (the regression harness) may *skip* on that rather than
|
||||||
/// fail, so this is the one believability entry point that does I/O. Tries both the
|
/// fail, so the `*_for_body` loaders are the believability entry points that do I/O.
|
||||||
/// repo-root and `server/`-relative paths so it works from either CWD.
|
/// Tries both the repo-root and `server/`-relative paths so it works from either CWD.
|
||||||
pub fn cascade_for_body(world_seed: u64, body_id: &str) -> Result<BodyWorldState, String> {
|
pub fn cascade_for_body(world_seed: u64, body_id: &str) -> Result<BodyWorldState, String> {
|
||||||
|
cascade_snapshot_for_body(world_seed, body_id)
|
||||||
|
.map(|(snapshot, _params)| snapshot.into_body_world_state())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`cascade_for_body`], but returns the raw [`CascadeSnapshot`] — which still
|
||||||
|
/// carries the transient `TerrainAnalysis` — together with the body's [`BodyParams`].
|
||||||
|
///
|
||||||
|
/// This is the entry point for callers that need the *on-demand* district-derivation
|
||||||
|
/// inputs (`district_profile::derive_district` wants `&TerrainAnalysis` +
|
||||||
|
/// `&BodyParams`), e.g. the aliveness probe's `--render` district-window mode
|
||||||
|
/// (T-1123). [`cascade_for_body`] is this plus the `BodyWorldState` conversion,
|
||||||
|
/// which drops the TerrainAnalysis per the D-203/T-1048 size budget — take it off
|
||||||
|
/// the snapshot *before* converting.
|
||||||
|
pub fn cascade_snapshot_for_body(
|
||||||
|
world_seed: u64,
|
||||||
|
body_id: &str,
|
||||||
|
) -> Result<(CascadeSnapshot, BodyParams), String> {
|
||||||
let db = first_existing(&["server/data/systems.db", "data/systems.db"])
|
let db = first_existing(&["server/data/systems.db", "data/systems.db"])
|
||||||
.ok_or_else(|| "systems.db not found".to_string())?;
|
.ok_or_else(|| "systems.db not found".to_string())?;
|
||||||
let hm_path =
|
let hm_path =
|
||||||
@@ -538,7 +555,7 @@ pub fn cascade_for_body(world_seed: u64, body_id: &str) -> Result<BodyWorldState
|
|||||||
Some(¶ms),
|
Some(¶ms),
|
||||||
CascadeLayer::Region,
|
CascadeLayer::Region,
|
||||||
);
|
);
|
||||||
Ok(snapshot.into_body_world_state())
|
Ok((snapshot, params))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read a body's settlements from `atlas_city_names`, mirroring
|
/// Read a body's settlements from `atlas_city_names`, mirroring
|
||||||
|
|||||||
@@ -15,18 +15,37 @@
|
|||||||
//! ```sh
|
//! ```sh
|
||||||
//! cargo run --bin aliveness_probe -- --body GJ338Bd --seed yolo --probes 5
|
//! cargo run --bin aliveness_probe -- --body GJ338Bd --seed yolo --probes 5
|
||||||
//! ```
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## `--render <out_dir>` — district-window maps (T-1123)
|
||||||
|
//!
|
||||||
|
//! Renders an N×N window (`--window`, default 64) of **true 2 km districts**
|
||||||
|
//! centred on the body's principal settlement, one PNG per attribute panel
|
||||||
|
//! (morphology / elev / temp / moisture / veg), via the pure on-demand
|
||||||
|
//! [`derive_district`] path. The orbit→jet-plane bridge demonstrator: nobody
|
||||||
|
//! had ever *seen* the district layer before this.
|
||||||
|
//!
|
||||||
|
//! ```sh
|
||||||
|
//! cargo run --bin aliveness_probe -- --body GJ380c --seed 42 \
|
||||||
|
//! --render /tmp/district-windows --window 64
|
||||||
|
//! ```
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use settled_reach_server::atlas::believability::{
|
use settled_reach_server::atlas::believability::{
|
||||||
analyze, cascade_for_body, evaluate_criteria, seed_to_u64, BelievabilityReport,
|
analyze, cascade_snapshot_for_body, evaluate_criteria, seed_to_u64, BelievabilityReport,
|
||||||
};
|
};
|
||||||
|
use settled_reach_server::atlas::body_world_state::BodyWorldState;
|
||||||
use settled_reach_server::atlas::chunk_context::derive_chunk_context;
|
use settled_reach_server::atlas::chunk_context::derive_chunk_context;
|
||||||
use settled_reach_server::atlas::district_profile::DistrictProfile;
|
use settled_reach_server::atlas::district_profile::{
|
||||||
|
derive_district, BodyParams, ClimateConstants, DistrictProfile, VegetationClass,
|
||||||
|
};
|
||||||
|
use settled_reach_server::atlas::features::TerrainAnalysis;
|
||||||
use settled_reach_server::atlas::scale::{
|
use settled_reach_server::atlas::scale::{
|
||||||
self, ChunkPos, DistrictPos, CHUNKS_PER_DISTRICT, CHUNK_M,
|
self, ChunkPos, DistrictPos, CHUNKS_PER_DISTRICT, CHUNK_M,
|
||||||
};
|
};
|
||||||
use settled_reach_server::atlas::voxel::derive_voxel_column;
|
use settled_reach_server::atlas::voxel::derive_voxel_column;
|
||||||
|
use settled_reach_server::seed::SeedChain;
|
||||||
|
use settled_reach_server::simulation::generator::MorphologyZone;
|
||||||
|
|
||||||
/// 150 chunks (the original question's offset) in metres = 9 600 m.
|
/// 150 chunks (the original question's offset) in metres = 9 600 m.
|
||||||
const ANCHOR_OFFSET_CHUNKS: i32 = 150;
|
const ANCHOR_OFFSET_CHUNKS: i32 = 150;
|
||||||
@@ -40,13 +59,29 @@ fn main() {
|
|||||||
args.body, args.seed, args.probes
|
args.body, args.seed, args.probes
|
||||||
);
|
);
|
||||||
|
|
||||||
let bws = match cascade_for_body(world_seed, &args.body) {
|
let (mut snapshot, body_params) = match cascade_snapshot_for_body(world_seed, &args.body) {
|
||||||
Ok(b) => b,
|
Ok(x) => x,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("cannot run cascade for {}: {e}", args.body);
|
eprintln!("cannot run cascade for {}: {e}", args.body);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// The transient TerrainAnalysis is the bilinear-envelope input the on-demand
|
||||||
|
// derive_district path (--render) needs for true 2 km districts. The cascade
|
||||||
|
// frees it once its own consumers (DistrictProfile + RoadGraph) have run
|
||||||
|
// (D-203/T-1048 size budget), so at Region depth `take()` yields None and we
|
||||||
|
// re-derive from the working heightmap — the path CascadeSnapshot documents
|
||||||
|
// ("callers that need it after the cascade must re-derive from run_layer1").
|
||||||
|
// Must happen BEFORE into_body_world_state() moves the heightmap out.
|
||||||
|
let terrain = match snapshot.terrain_analysis.take() {
|
||||||
|
Some(ta) => Some(ta),
|
||||||
|
None if args.render.is_some() => {
|
||||||
|
Some(settled_reach_server::atlas::layer1::run_layer1(&snapshot.heightmap).1)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let body_seed = snapshot.seed;
|
||||||
|
let bws = snapshot.into_body_world_state();
|
||||||
|
|
||||||
let districts = &bws.districts;
|
let districts = &bws.districts;
|
||||||
if districts.is_empty() {
|
if districts.is_empty() {
|
||||||
@@ -110,6 +145,16 @@ fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!("\n(district tier — voxel addressing is not production-wired yet; voxel rows are illustrative ground-truth for a representative chunk of each district.)");
|
println!("\n(district tier — voxel addressing is not production-wired yet; voxel rows are illustrative ground-truth for a representative chunk of each district.)");
|
||||||
|
|
||||||
|
// ── --render: district-window PNG maps (T-1123) ──────────────────────────
|
||||||
|
if let Some(out_dir) = &args.render {
|
||||||
|
match &terrain {
|
||||||
|
Some(ta) => render_district_windows(out_dir, &args, body_seed, &body_params, ta, &bws),
|
||||||
|
None => eprintln!(
|
||||||
|
"--render: cascade retained no TerrainAnalysis — cannot render district windows."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Print the body-level believability report + the advisory D-245 criteria — the
|
/// Print the body-level believability report + the advisory D-245 criteria — the
|
||||||
@@ -254,6 +299,354 @@ fn nearest_present(
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// --render: district-window PNG maps (T-1123)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// N×N windows of TRUE 2 km districts around the principal settlement, one PNG
|
||||||
|
// per attribute panel, straight from the pure on-demand `derive_district`
|
||||||
|
// path. Deliberately NOT `bws.districts`: that eager map is the coarse
|
||||||
|
// working-grid pseudo-grid (gcpr = 8 heightmap cells per "district", tens of
|
||||||
|
// km per cell) — the whole point here is true 2 km fidelity.
|
||||||
|
|
||||||
|
/// `MorphologyZone` discriminant → RGB. Transliterated from the client overlay
|
||||||
|
/// palette — `client/ui/implant/apps/atlas/atlas_marker_overlay.gd`,
|
||||||
|
/// `MORPHOLOGY_COLORS` (D-239 §6 discriminant order, T-1046) — which is the
|
||||||
|
/// source of truth; **keep the two in sync** (same order, same 17 entries).
|
||||||
|
/// The client's 0.55 alpha is dropped: these PNGs are opaque, no heightmap
|
||||||
|
/// underlay to blend with.
|
||||||
|
const MORPHOLOGY_RGB: [[u8; 3]; 17] = [
|
||||||
|
[26, 51, 115], // 0 OpenOcean
|
||||||
|
[51, 102, 166], // 1 Lake
|
||||||
|
[115, 140, 128], // 2 TidalFlat
|
||||||
|
[217, 199, 115], // 3 DuneStrand
|
||||||
|
[128, 128, 140], // 4 CliffCoast
|
||||||
|
[77, 102, 128], // 5 Fjord
|
||||||
|
[102, 166, 153], // 6 Delta
|
||||||
|
[77, 140, 140], // 7 Estuarine
|
||||||
|
[77, 153, 77], // 8 AlluvialPlain
|
||||||
|
[115, 179, 102], // 9 RiverBank
|
||||||
|
[89, 153, 128], // 10 MeanderReach
|
||||||
|
[140, 153, 115], // 11 BraidedPlain
|
||||||
|
[128, 140, 77], // 12 ValleyFloor
|
||||||
|
[140, 115, 77], // 13 MountainPass
|
||||||
|
[204, 209, 217], // 14 Alpine
|
||||||
|
[115, 38, 31], // 15 Volcanic
|
||||||
|
[64, 115, 102], // 16 Wetland
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The five attribute panels, in output order.
|
||||||
|
const PANEL_NAMES: [&str; 5] = ["morphology", "elev", "temp", "moisture", "veg"];
|
||||||
|
|
||||||
|
/// Render the district window around the principal settlement and write one PNG
|
||||||
|
/// per panel to `out_dir`, plus a one-line manifest per image and a determinism
|
||||||
|
/// spot-proof (two full derive+encode passes must be byte-identical — D-010 in
|
||||||
|
/// miniature).
|
||||||
|
fn render_district_windows(
|
||||||
|
out_dir: &str,
|
||||||
|
args: &Args,
|
||||||
|
body_seed: SeedChain,
|
||||||
|
params: &BodyParams,
|
||||||
|
ta: &TerrainAnalysis,
|
||||||
|
bws: &BodyWorldState,
|
||||||
|
) {
|
||||||
|
// Anchor: `--anchor <substr>` (case-insensitive name match) when given,
|
||||||
|
// else the capital placement when one exists, else the most populous,
|
||||||
|
// else the best-scoring.
|
||||||
|
let named = args.anchor.as_ref().and_then(|pat| {
|
||||||
|
let pat_lower = pat.to_lowercase();
|
||||||
|
let found = bws
|
||||||
|
.placements
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.name.to_lowercase().contains(&pat_lower));
|
||||||
|
if found.is_none() {
|
||||||
|
eprintln!(
|
||||||
|
"--anchor \"{pat}\": no placement name matches — using the principal settlement"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
found
|
||||||
|
});
|
||||||
|
let Some(anchor) = named.or_else(|| {
|
||||||
|
bws.placements
|
||||||
|
.iter()
|
||||||
|
.max_by_key(|p| (p.is_capital, p.population, p.score))
|
||||||
|
}) else {
|
||||||
|
eprintln!("--render: body has no settlement placements — nothing to anchor a window on.");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let centre = true_district_of_pixel(anchor.position, ta.w, ta.h, params.body_radius_km);
|
||||||
|
if let Err(e) = std::fs::create_dir_all(out_dir) {
|
||||||
|
eprintln!("--render: cannot create {out_dir}: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let n = args.window.max(2);
|
||||||
|
let extent_km = n as f64 * scale::DISTRICT_M as f64 / 1000.0;
|
||||||
|
let climate = ClimateConstants::default();
|
||||||
|
|
||||||
|
println!("\n---- DISTRICT WINDOW RENDER (T-1123) ----");
|
||||||
|
println!(
|
||||||
|
"anchor: {} (capital={}, pop={}) @ working pixel {:?} → true district ({}, {})",
|
||||||
|
anchor.name, anchor.is_capital, anchor.population, anchor.position, centre.0, centre.1
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"window: {n}×{n} districts = {extent_km:.1}×{extent_km:.1} km @ 2.048 km/px (seed \"{}\")",
|
||||||
|
args.seed
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determinism spot-proof: two independent full passes, byte-compared.
|
||||||
|
let t0 = std::time::Instant::now();
|
||||||
|
let first = render_window_panels(body_seed, &bws.body_id, params, ta, centre, n, &climate);
|
||||||
|
let pass1_ms = t0.elapsed().as_millis();
|
||||||
|
let t1 = std::time::Instant::now();
|
||||||
|
let second = render_window_panels(body_seed, &bws.body_id, params, ta, centre, n, &climate);
|
||||||
|
let pass2_ms = t1.elapsed().as_millis();
|
||||||
|
let identical = first
|
||||||
|
.iter()
|
||||||
|
.zip(second.iter())
|
||||||
|
.filter(|((_, a), (_, b))| a == b)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
let body_lower = args.body.to_lowercase();
|
||||||
|
for (panel, png_bytes) in &first {
|
||||||
|
let file = format!("{out_dir}/{body_lower}_w{n}_{panel}.png");
|
||||||
|
if let Err(e) = std::fs::write(&file, png_bytes) {
|
||||||
|
eprintln!("--render: write {file}: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"RENDER body={} seed=\"{}\" window={n} extent_km={extent_km:.1} \
|
||||||
|
anchor_city=\"{}\" anchor_district=({},{}) panel={panel} file={file}",
|
||||||
|
args.body, args.seed, anchor.name, centre.0, centre.1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"DETERMINISM: {identical}/{} panels byte-identical across two full derive+encode passes \
|
||||||
|
[{}] — pass1 {pass1_ms} ms, pass2 {pass2_ms} ms ({} district derivations/pass)",
|
||||||
|
first.len(),
|
||||||
|
if identical == first.len() {
|
||||||
|
"OK"
|
||||||
|
} else {
|
||||||
|
"MISMATCH"
|
||||||
|
},
|
||||||
|
(n as u64) * (n as u64),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive every district in the window and encode the five attribute panels
|
||||||
|
/// as PNG byte vectors (encode included so the determinism proof covers the
|
||||||
|
/// full derive→encode pipeline).
|
||||||
|
fn render_window_panels(
|
||||||
|
seed: SeedChain,
|
||||||
|
body_id: &str,
|
||||||
|
params: &BodyParams,
|
||||||
|
ta: &TerrainAnalysis,
|
||||||
|
centre: DistrictPos,
|
||||||
|
n: u32,
|
||||||
|
climate: &ClimateConstants,
|
||||||
|
) -> Vec<(&'static str, Vec<u8>)> {
|
||||||
|
let n_i = n as i32;
|
||||||
|
let half = n_i / 2;
|
||||||
|
let mut bufs: [Vec<u8>; 5] = std::array::from_fn(|_| vec![0u8; (n * n) as usize * 3]);
|
||||||
|
for row in 0..n_i {
|
||||||
|
for col in 0..n_i {
|
||||||
|
// Row 0 = northmost: smaller dy = further north (derive_district maps
|
||||||
|
// negative wy to negative lat_frac = northern latitudes).
|
||||||
|
let dp = (centre.0 - half + col, centre.1 - half + row);
|
||||||
|
let prof = derive_district(seed, body_id, params, ta, dp, climate);
|
||||||
|
let i = ((row * n_i + col) * 3) as usize;
|
||||||
|
set_rgb(&mut bufs[0], i, morphology_rgb(prof.morphology_zone));
|
||||||
|
set_rgb(&mut bufs[1], i, gray_rgb(prof.elev_q));
|
||||||
|
set_rgb(&mut bufs[2], i, temperature_rgb(prof.temperature_c));
|
||||||
|
set_rgb(&mut bufs[3], i, moisture_rgb(prof.moisture_q));
|
||||||
|
set_rgb(&mut bufs[4], i, vegetation_rgb(prof.vegetation_class));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for buf in &mut bufs {
|
||||||
|
draw_anchor_crosshair(buf, n_i, half, half);
|
||||||
|
}
|
||||||
|
PANEL_NAMES
|
||||||
|
.iter()
|
||||||
|
.zip(bufs)
|
||||||
|
.map(|(name, buf)| (*name, encode_rgb_png(n, n, &buf)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a working-grid settlement pixel `(row, col)` to the TRUE metric 2 km
|
||||||
|
/// district containing it — the inverse of `derive_district`'s forward mapping
|
||||||
|
/// (district → fractional working-grid position via the body radius, the D-204
|
||||||
|
/// elastic seam). NOT `scale::heightmap_pixel_to_district`: that addresses the
|
||||||
|
/// eager gcpr = 8 pseudo-grid, whose cells span tens of km.
|
||||||
|
fn true_district_of_pixel(
|
||||||
|
pixel: (u16, u16),
|
||||||
|
ta_w: usize,
|
||||||
|
ta_h: usize,
|
||||||
|
radius_km: Option<f64>,
|
||||||
|
) -> DistrictPos {
|
||||||
|
let (row, col) = (pixel.0 as f64, pixel.1 as f64);
|
||||||
|
match radius_km {
|
||||||
|
Some(r) if r > 0.0 => {
|
||||||
|
let circumference_m = std::f64::consts::TAU * r * 1000.0;
|
||||||
|
let meridian_m = std::f64::consts::PI * r * 1000.0;
|
||||||
|
let wx = col / ta_w.max(1) as f64 * circumference_m;
|
||||||
|
let lat_frac = if ta_h > 1 {
|
||||||
|
row / (ta_h - 1) as f64 - 0.5
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let wy = lat_frac * meridian_m;
|
||||||
|
(
|
||||||
|
(wx / scale::DISTRICT_M as f64).round() as i32,
|
||||||
|
(wy / scale::DISTRICT_M as f64).round() as i32,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// No radius: the district grid IS the working grid (derive_district's
|
||||||
|
// tiny-test-body fallback) — the pixel is the district.
|
||||||
|
_ => (col as i32, row as i32),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn set_rgb(buf: &mut [u8], i: usize, c: [u8; 3]) {
|
||||||
|
buf[i..i + 3].copy_from_slice(&c);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn morphology_rgb(zone: MorphologyZone) -> [u8; 3] {
|
||||||
|
// Out-of-palette discriminant (enum grew, palette didn't) → magenta sentinel,
|
||||||
|
// and the unit test below fails until both palettes are extended.
|
||||||
|
*MORPHOLOGY_RGB
|
||||||
|
.get(zone as u8 as usize)
|
||||||
|
.unwrap_or(&[255, 0, 255])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `elev_q` 0–100 → 8-bit grayscale.
|
||||||
|
fn gray_rgb(elev_q: i32) -> [u8; 3] {
|
||||||
|
let g = (elev_q.clamp(0, 100) * 255 / 100) as u8;
|
||||||
|
[g, g, g]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Heat ramp over −60…+40 °C (clamped; piecewise blue→cyan→yellow→red).
|
||||||
|
/// Airless bodies (`temperature_c == None`, D-239 §2) render as a magenta
|
||||||
|
/// sentinel — "no atmosphere" must never be readable as a temperature.
|
||||||
|
fn temperature_rgb(t: Option<f32>) -> [u8; 3] {
|
||||||
|
const STOPS: [(f32, [f32; 3]); 4] = [
|
||||||
|
(-60.0, [0.0, 0.0, 255.0]),
|
||||||
|
(-20.0, [0.0, 255.0, 255.0]),
|
||||||
|
(10.0, [255.0, 255.0, 0.0]),
|
||||||
|
(40.0, [255.0, 0.0, 0.0]),
|
||||||
|
];
|
||||||
|
let Some(t) = t else { return [255, 0, 255] };
|
||||||
|
let t = t.clamp(STOPS[0].0, STOPS[STOPS.len() - 1].0);
|
||||||
|
for w in STOPS.windows(2) {
|
||||||
|
let (t0, c0) = w[0];
|
||||||
|
let (t1, c1) = w[1];
|
||||||
|
if t <= t1 {
|
||||||
|
return lerp_rgb(c0, c1, ((t - t0) / (t1 - t0)).clamp(0.0, 1.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[255, 0, 0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dry→wet ramp: parched tan → grass green → deep water blue (`moisture_q` 0–100).
|
||||||
|
fn moisture_rgb(q: i32) -> [u8; 3] {
|
||||||
|
let q = q.clamp(0, 100) as f32;
|
||||||
|
if q <= 50.0 {
|
||||||
|
lerp_rgb([150.0, 110.0, 70.0], [90.0, 160.0, 90.0], q / 50.0)
|
||||||
|
} else {
|
||||||
|
lerp_rgb([90.0, 160.0, 90.0], [30.0, 90.0, 200.0], (q - 50.0) / 50.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Categorical vegetation palette ([`VegetationClass`], D-239 §8 ladder).
|
||||||
|
fn vegetation_rgb(v: VegetationClass) -> [u8; 3] {
|
||||||
|
match v {
|
||||||
|
VegetationClass::Absent => [40, 40, 40], // airless — near-black
|
||||||
|
VegetationClass::Barren => [160, 140, 110], // bare-ground tan
|
||||||
|
VegetationClass::Scrub => [140, 160, 80], // olive
|
||||||
|
VegetationClass::Forest => [30, 110, 40], // closed canopy
|
||||||
|
VegetationClass::RiparianScrub => [70, 170, 120], // waterway band (open)
|
||||||
|
VegetationClass::RiparianThicket => [10, 130, 90], // waterway band (dense)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lerp_rgb(a: [f32; 3], b: [f32; 3], t: f32) -> [u8; 3] {
|
||||||
|
let mix = |i: usize| (a[i] + (b[i] - a[i]) * t).round().clamp(0.0, 255.0) as u8;
|
||||||
|
[mix(0), mix(1), mix(2)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// White crosshair (±3 px arms) with darkened diagonal shoulders at the anchor
|
||||||
|
/// pixel, so the marker stays visible on dark (ocean) and light (alpine) panels
|
||||||
|
/// alike and the window orients against the planetary Atlas shots.
|
||||||
|
fn draw_anchor_crosshair(buf: &mut [u8], n: i32, cx: i32, cy: i32) {
|
||||||
|
let mut put = |x: i32, y: i32, c: [u8; 3]| {
|
||||||
|
if x >= 0 && x < n && y >= 0 && y < n {
|
||||||
|
set_rgb(buf, ((y * n + x) * 3) as usize, c);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (dx, dy) in [(-1, -1), (1, -1), (-1, 1), (1, 1)] {
|
||||||
|
put(cx + dx, cy + dy, [0, 0, 0]);
|
||||||
|
}
|
||||||
|
for d in -3i32..=3 {
|
||||||
|
put(cx + d, cy, [255, 255, 255]);
|
||||||
|
put(cx, cy + d, [255, 255, 255]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode an 8-bit RGB buffer as PNG bytes (pattern from `heightmap.rs`).
|
||||||
|
fn encode_rgb_png(w: u32, h: u32, rgb: &[u8]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
{
|
||||||
|
let mut enc = png::Encoder::new(&mut out, w, h);
|
||||||
|
enc.set_color(png::ColorType::Rgb);
|
||||||
|
enc.set_depth(png::BitDepth::Eight);
|
||||||
|
let mut writer = enc.write_header().expect("png header");
|
||||||
|
writer.write_image_data(rgb).expect("png data");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The palette is indexed by `MorphologyZone` discriminant (repr(u8),
|
||||||
|
/// append-only). Pinning the LAST variant's discriminant against the
|
||||||
|
/// palette length forces a palette extension — here AND in the client
|
||||||
|
/// source of truth (`atlas_marker_overlay.gd::MORPHOLOGY_COLORS`) — when
|
||||||
|
/// a zone is appended.
|
||||||
|
#[test]
|
||||||
|
fn morphology_palette_covers_every_zone() {
|
||||||
|
assert_eq!(MorphologyZone::Wetland as usize + 1, MORPHOLOGY_RGB.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true_district_of_pixel` must invert `derive_district`'s forward mapping
|
||||||
|
/// (district → fractional working-grid position via the body radius) to
|
||||||
|
/// within the half-district rounding error — ≲0.01 working pixel at
|
||||||
|
/// planetary radii.
|
||||||
|
#[test]
|
||||||
|
fn true_district_inverts_forward_mapping() {
|
||||||
|
let (w, h) = (128usize, 64usize);
|
||||||
|
let r_km = 6371.0;
|
||||||
|
let circ_m = std::f64::consts::TAU * r_km * 1000.0;
|
||||||
|
let mer_m = std::f64::consts::PI * r_km * 1000.0;
|
||||||
|
for (row, col) in [(10u16, 100u16), (32, 0), (5, 127), (63, 64)] {
|
||||||
|
let (dx, dy) = true_district_of_pixel((row, col), w, h, Some(r_km));
|
||||||
|
let px = ((dx as f64 * scale::DISTRICT_M as f64) / circ_m).rem_euclid(1.0) * w as f64;
|
||||||
|
let py = (0.5 + ((dy as f64 * scale::DISTRICT_M as f64) / mer_m).clamp(-0.5, 0.5))
|
||||||
|
* (h - 1) as f64;
|
||||||
|
assert!((px - col as f64).abs() < 0.05, "px {px} vs col {col}");
|
||||||
|
assert!((py - row as f64).abs() < 0.05, "py {py} vs row {row}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Without a radius the fallback maps pixel → district 1:1 (mirrors
|
||||||
|
/// `derive_district`'s tiny-test-body branch).
|
||||||
|
#[test]
|
||||||
|
fn true_district_fallback_is_identity() {
|
||||||
|
assert_eq!(true_district_of_pixel((7, 3), 128, 64, None), (3, 7));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Args
|
// Args
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -262,6 +655,13 @@ struct Args {
|
|||||||
body: String,
|
body: String,
|
||||||
seed: String,
|
seed: String,
|
||||||
probes: u32,
|
probes: u32,
|
||||||
|
/// `--render <out_dir>` — write district-window PNG panels (T-1123).
|
||||||
|
render: Option<String>,
|
||||||
|
/// `--window <N>` — window side in districts (default 64 ≈ 131 km).
|
||||||
|
window: u32,
|
||||||
|
/// `--anchor <substr>` — anchor the window on the placement whose name
|
||||||
|
/// matches (case-insensitive); default: capital, else most populous.
|
||||||
|
anchor: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Args {
|
impl Args {
|
||||||
@@ -269,15 +669,28 @@ impl Args {
|
|||||||
let mut body = "GJ338Bd".to_string();
|
let mut body = "GJ338Bd".to_string();
|
||||||
let mut seed = "yolo".to_string();
|
let mut seed = "yolo".to_string();
|
||||||
let mut probes = 5u32;
|
let mut probes = 5u32;
|
||||||
|
let mut render = None;
|
||||||
|
let mut window = 64u32;
|
||||||
|
let mut anchor = None;
|
||||||
let mut it = args.peekable();
|
let mut it = args.peekable();
|
||||||
while let Some(a) = it.next() {
|
while let Some(a) = it.next() {
|
||||||
match a.as_str() {
|
match a.as_str() {
|
||||||
"--body" => body = it.next().unwrap_or(body),
|
"--body" => body = it.next().unwrap_or(body),
|
||||||
"--seed" => seed = it.next().unwrap_or(seed),
|
"--seed" => seed = it.next().unwrap_or(seed),
|
||||||
"--probes" => probes = it.next().and_then(|s| s.parse().ok()).unwrap_or(probes),
|
"--probes" => probes = it.next().and_then(|s| s.parse().ok()).unwrap_or(probes),
|
||||||
|
"--render" => render = it.next(),
|
||||||
|
"--window" => window = it.next().and_then(|s| s.parse().ok()).unwrap_or(window),
|
||||||
|
"--anchor" => anchor = it.next(),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Args { body, seed, probes }
|
Args {
|
||||||
|
body,
|
||||||
|
seed,
|
||||||
|
probes,
|
||||||
|
render,
|
||||||
|
window,
|
||||||
|
anchor,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user