Merge remote-tracking branch 'origin/district-window-render'
This commit is contained in:
@@ -45,9 +45,9 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::atlas::attractor_matching::CityRecord;
|
||||
use crate::atlas::body_params_reader::BodyParamsReader;
|
||||
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::district_profile::DistrictProfile;
|
||||
use crate::atlas::district_profile::{BodyParams, DistrictProfile};
|
||||
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::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
|
||||
/// 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
|
||||
/// repo-root and `server/`-relative paths so it works from either CWD.
|
||||
/// fail, so the `*_for_body` loaders are the believability entry points that do I/O.
|
||||
/// 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> {
|
||||
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"])
|
||||
.ok_or_else(|| "systems.db not found".to_string())?;
|
||||
let hm_path =
|
||||
@@ -538,7 +555,7 @@ pub fn cascade_for_body(world_seed: u64, body_id: &str) -> Result<BodyWorldState
|
||||
Some(¶ms),
|
||||
CascadeLayer::Region,
|
||||
);
|
||||
Ok(snapshot.into_body_world_state())
|
||||
Ok((snapshot, params))
|
||||
}
|
||||
|
||||
/// Read a body's settlements from `atlas_city_names`, mirroring
|
||||
|
||||
@@ -15,18 +15,37 @@
|
||||
//! ```sh
|
||||
//! 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 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::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::{
|
||||
self, ChunkPos, DistrictPos, CHUNKS_PER_DISTRICT, CHUNK_M,
|
||||
};
|
||||
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.
|
||||
const ANCHOR_OFFSET_CHUNKS: i32 = 150;
|
||||
@@ -40,13 +59,29 @@ fn main() {
|
||||
args.body, args.seed, args.probes
|
||||
);
|
||||
|
||||
let bws = match cascade_for_body(world_seed, &args.body) {
|
||||
Ok(b) => b,
|
||||
let (mut snapshot, body_params) = match cascade_snapshot_for_body(world_seed, &args.body) {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
eprintln!("cannot run cascade for {}: {e}", args.body);
|
||||
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;
|
||||
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.)");
|
||||
|
||||
// ── --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
|
||||
@@ -254,6 +299,312 @@ fn nearest_present(
|
||||
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
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Args
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -262,6 +613,13 @@ struct Args {
|
||||
body: String,
|
||||
seed: String,
|
||||
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 {
|
||||
@@ -269,15 +627,70 @@ impl Args {
|
||||
let mut body = "GJ338Bd".to_string();
|
||||
let mut seed = "yolo".to_string();
|
||||
let mut probes = 5u32;
|
||||
let mut render = None;
|
||||
let mut window = 64u32;
|
||||
let mut anchor = None;
|
||||
let mut it = args.peekable();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--body" => body = it.next().unwrap_or(body),
|
||||
"--seed" => seed = it.next().unwrap_or(seed),
|
||||
"--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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user