feat(simulation): detail-scatter synthesis + on-demand 2km derive_district (T-1077)

Phase 3 of the D-243 re-scale — the corrected 2km carrier and its 'stretch
magic'.

- detail_scatter.rs: the mid-scale terrain (2-40km) invented between coarse
  heightmap samples. Locked character (T-1077): ADAPTIVE (ruggedness follows
  local slope/morphology -> gentle flats, rugged highs) + ENVELOPE+MODULATION
  (heightmap relief caps amplitude; morphology flavors within). Deterministic
  seed+position value-noise (D-227/D-010); C1-continuous, no lattice creases.
- derive_district(): the on-demand 2km carrier. Maps the district to a
  fractional heightmap pixel via body_radius_km (the elastic seam, D-204),
  bilinearly interpolates the L1 envelope, composes the scatter, derives the
  profile. body_radius None falls back to direct indexing (tiny test bodies).
- Factored build_district_profile() (climate+morphology tail) shared by the
  cell-aggregate and interpolation paths.

5 scatter tests + 5 derive_district tests (determinism, latitude->climate via
the seam, the envelope rule, radius fallback). Full suite + clippy -D warnings
green; existing goldens untouched (additive).

Scope: T-1077 delivers the corrected on-demand 2km derivation + scatter. The
eager-grid -> on-demand region/district production + Atlas surfacing (decision
A) is T-1046's chartered job (derive_all_districts stays the coarse Atlas grid
for now, documented).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 10:00:01 +02:00
co-authored by Claude Opus 4.8
parent b3b5bfc2e3
commit e96ed0e9e4
3 changed files with 412 additions and 3 deletions
+249 -3
View File
@@ -23,6 +23,7 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::atlas::features::TerrainAnalysis;
use crate::atlas::scale;
use crate::seed::SeedChain;
use crate::simulation::generator::MorphologyZone;
@@ -950,6 +951,28 @@ pub fn derive_district_profile(
(0, 0, 0)
};
build_district_profile(
seed,
body_params,
climate,
slope_q,
elev_q,
ocean_fraction_q,
)
}
/// Build the climate + morphology fields of a `DistrictProfile` from its three
/// terrain primitives (`slope_q`, `elev_q`, `ocean_fraction_q`) — the shared tail
/// of every derivation path (cell-aggregate [`derive_district_profile`] and the
/// interpolation+scatter [`derive_district`]). Pure (T-1024, D-239 §2 / D-240).
fn build_district_profile(
seed: SeedChain,
body_params: &BodyParams,
climate: &ClimateConstants,
slope_q: i32,
elev_q: i32,
ocean_fraction_q: i32,
) -> DistrictProfile {
let tectonic_class = derive_tectonic_class(body_params);
// Climate derivation (T-1024, D-239 §2). Temperature lapse must vary by THIS
@@ -959,13 +982,13 @@ pub fn derive_district_profile(
// district's own normalized elevation (elev_q, 0–100) scaled to the body's
// elevation span. district_latitude_deg is already per-district (set by the caller
// / derive_all_districts). Per-cell refinement happens later at ChunkContext.
let region_climate_params = BodyParams {
let district_climate_params = BodyParams {
elevation_km: (elev_q as f64 / 100.0) * MAX_REGION_ELEVATION_KM,
..body_params.clone()
};
// D-240: body-scoped seed for the deterministic per-body temperature nudge.
let body_seed = seed.seed();
let temperature_c = derive_temperature_c(&region_climate_params, climate, body_seed);
let temperature_c = derive_temperature_c(&district_climate_params, climate, body_seed);
let moisture_q = derive_moisture_q(body_params);
// Climate-derived fields: computed from temperature + moisture primitives
@@ -1004,7 +1027,140 @@ pub fn derive_district_profile(
}
}
/// Derive district profiles for all districts covering the body.
/// On-demand 2 km district profile (D-243 §2, T-1077) — the corrected carrier.
///
/// Maps the district to a fractional heightmap position via the body radius (the
/// elastic seam, D-204), bilinearly interpolates the Layer-1 terrain (the
/// continental *envelope*), then composes the adaptive detail-scatter
/// ([`crate::atlas::detail_scatter`]) for the mid-scale relief the heightmap is
/// too coarse to carry. `body_radius_km = None` (e.g. tiny test bodies) falls back
/// to direct heightmap indexing.
///
/// Pure and deterministic (D-227/D-010): a function of
/// `(seed, body_params, terrain, district_pos)`; the f64 scatter is quantised to
/// integer `slope_q`/`elev_q` at the decision boundary.
pub fn derive_district(
seed: SeedChain,
body_params: &BodyParams,
ta: &TerrainAnalysis,
district_pos: DistrictPos,
climate: &ClimateConstants,
) -> DistrictProfile {
let (dx, dy) = district_pos;
// District → fractional heightmap pixel + world-metre coordinate + latitude.
let (px, py, world_x_m, world_y_m, lat_deg) = match body_params.body_radius_km {
Some(r_km) if r_km > 0.0 => {
let circumference_m = std::f64::consts::TAU * r_km * 1000.0;
let meridian_m = std::f64::consts::PI * r_km * 1000.0;
let wx = dx as f64 * scale::DISTRICT_M as f64;
let wy = dy as f64 * scale::DISTRICT_M as f64;
// Longitude wraps; district (0,0) sits at lon 0 / the equator.
let px = (wx / circumference_m).rem_euclid(1.0) * ta.w as f64;
// Latitude: equator at py = h/2, clamped at the poles.
let lat_frac = (wy / meridian_m).clamp(-0.5, 0.5); // −0.5 = N pole, +0.5 = S
let py = (0.5 + lat_frac) * ta.h.saturating_sub(1) as f64;
(px, py, wx, wy, -lat_frac * 180.0)
}
_ => {
// No radius: the district grid IS the heightmap grid (tiny test bodies).
let px = (dx as f64).clamp(0.0, ta.w.saturating_sub(1) as f64);
let py = (dy as f64).clamp(0.0, ta.h.saturating_sub(1) as f64);
let lat_deg = if ta.h > 1 {
90.0 - (py / (ta.h - 1) as f64) * 180.0
} else {
0.0
};
let dm = scale::DISTRICT_M as f64;
(px, py, px * dm, py * dm, lat_deg)
}
};
// Bilinear-interpolated Layer-1 envelope at the district position.
let elev_pct = bilinear(&ta.elev_pct, ta.w, ta.h, px, py) as f64;
let slope_deg = bilinear(&ta.slope_deg, ta.w, ta.h, px, py) as f64;
let ocean_frac = bilinear_bool(&ta.ocean_mask, ta.w, ta.h, px, py) as f64;
// Envelope + adaptive ruggedness from the local heightmap slope (steep
// heightmap ⇒ relief headroom ⇒ rugged). Both 0–1.
let local_slope = (slope_deg / 45.0).clamp(0.0, 1.0);
let envelope = local_slope;
let ruggedness = local_slope;
let scatter = crate::atlas::detail_scatter::terrain_detail(
seed.seed(),
world_x_m,
world_y_m,
envelope,
ruggedness,
);
// Compose the primitives. Scatter perturbs elevation (mid-scale relief); slope
// gains a ruggedness-weighted bump so the morphology classifier responds
// (adaptive: rugged highs → mountain/pass, flats → plains).
let elev_q = (((elev_pct + scatter) * 100.0).round() as i32).clamp(0, 100);
let slope_q =
(((local_slope + ruggedness * scatter.abs()) * 100.0).round() as i32).clamp(0, 100);
let ocean_fraction_q = ((ocean_frac * 100.0).round() as i32).clamp(0, 100);
let params = BodyParams {
district_latitude_deg: lat_deg,
..body_params.clone()
};
build_district_profile(seed, &params, climate, slope_q, elev_q, ocean_fraction_q)
}
/// Bilinear interpolation of a row-major `f32` field at fractional `(px, py)`.
/// Columns wrap (equirectangular); rows clamp at the poles.
fn bilinear(field: &[f32], w: usize, h: usize, px: f64, py: f64) -> f32 {
if w == 0 || h == 0 {
return 0.0;
}
let x0 = px.floor();
let y0 = py.floor().clamp(0.0, (h - 1) as f64);
let tx = (px - x0) as f32;
let ty = (py - y0) as f32;
let ix0 = (x0 as i64).rem_euclid(w as i64) as usize;
let ix1 = (ix0 + 1) % w;
let iy0 = (y0 as usize).min(h - 1);
let iy1 = (iy0 + 1).min(h - 1);
let v00 = field[iy0 * w + ix0];
let v10 = field[iy0 * w + ix1];
let v01 = field[iy1 * w + ix0];
let v11 = field[iy1 * w + ix1];
let a = v00 + (v10 - v00) * tx;
let b = v01 + (v11 - v01) * tx;
a + (b - a) * ty
}
/// Bilinear interpolation of a boolean mask as a 0–1 fraction (for ocean coverage).
fn bilinear_bool(mask: &[bool], w: usize, h: usize, px: f64, py: f64) -> f32 {
if w == 0 || h == 0 {
return 0.0;
}
let x0 = px.floor();
let y0 = py.floor().clamp(0.0, (h - 1) as f64);
let tx = (px - x0) as f32;
let ty = (py - y0) as f32;
let ix0 = (x0 as i64).rem_euclid(w as i64) as usize;
let ix1 = (ix0 + 1) % w;
let iy0 = (y0 as usize).min(h - 1);
let iy1 = (iy0 + 1).min(h - 1);
let f = |r: usize, c: usize| mask[r * w + c] as i32 as f32;
let a = f(iy0, ix0) + (f(iy0, ix1) - f(iy0, ix0)) * tx;
let b = f(iy1, ix0) + (f(iy1, ix1) - f(iy1, ix0)) * tx;
a + (b - a) * ty
}
/// Eagerly derive a coarse profile grid covering the body, by direct heightmap
/// tiling (`grid_cells_per_district` cells per cell).
///
/// **Scale note (D-243, T-1077):** this is the *coarse* eager grid — one cell per
/// `gcpr` heightmap pixels (tens-to-hundreds of km) — kept as the Atlas zone
/// overlay source. The **corrected 2 km carrier** the voxel chain consumes is the
/// on-demand [`derive_district`] (heightmap interpolation + detail-scatter via the
/// elastic seam). Swapping production from this eager grid to on-demand
/// district/region surfacing is the chartered job of T-1046; T-1077 provides the
/// correct on-demand derivation, not the eager/Atlas restructure.
///
/// Returns a `BTreeMap<DistrictPos, DistrictProfile>` covering the full
/// heightmap at the given district-grid resolution.
@@ -1101,6 +1257,96 @@ mod tests {
assert_eq!(districts.len(), 32, "district count mismatch");
}
// --- derive_district (on-demand 2 km, interpolation + detail-scatter) -----
fn earth_params() -> BodyParams {
BodyParams {
hydrosphere: Some("ocean".into()),
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
body_radius_km: Some(6371.0),
..Default::default()
}
}
#[test]
fn derive_district_is_deterministic() {
let hm = test_hm();
let ta = test_ta(&hm);
let climate = ClimateConstants::default();
let p = earth_params();
let a = derive_district(test_seed(), &p, &ta, (1234, -567), &climate);
let b = derive_district(test_seed(), &p, &ta, (1234, -567), &climate);
assert_eq!(a.elev_q, b.elev_q);
assert_eq!(a.slope_q, b.slope_q);
assert_eq!(a.morphology_zone, b.morphology_zone);
assert_eq!(a.temperature_c, b.temperature_c);
}
#[test]
fn derive_district_radius_maps_to_latitude_climate() {
// With a body radius, equatorial vs near-polar districts get different
// temperature (the seam maps district_y → latitude). Pole = colder.
let hm = test_hm();
let ta = test_ta(&hm);
let climate = ClimateConstants::default();
let p = earth_params();
// meridian ≈ π·6371·1000 m; a district near the pole is ~quarter-meridian away.
let merid_districts =
(std::f64::consts::PI * 6371.0 * 1000.0 / scale::DISTRICT_M as f64) as i32;
let equator = derive_district(test_seed(), &p, &ta, (0, 0), &climate);
let high_lat =
derive_district(test_seed(), &p, &ta, (0, merid_districts / 2 - 2), &climate);
match (equator.temperature_c, high_lat.temperature_c) {
(Some(eq), Some(hi)) => {
assert!(hi < eq, "near-pole district must be colder ({hi} !< {eq})")
}
_ => panic!("breathable body must have a temperature"),
}
}
#[test]
fn derive_district_flat_envelope_invents_no_relief() {
// A perfectly flat heightmap (no slope) → envelope 0 → scatter invents
// nothing → elev_q equals the interpolated base (the envelope rule).
let (w, h) = (64u32, 32u32);
let flat = BodyHeightmap {
body_id: "flat".into(),
width: w,
height: h,
data: vec![0.6; (w * h) as usize],
sea_level: 0.3,
};
let ta = test_ta(&flat);
let climate = ClimateConstants::default();
let p = earth_params();
let d = derive_district(test_seed(), &p, &ta, (500, 100), &climate);
// Flat land everywhere → slope 0 → no invented relief, no ocean.
assert_eq!(
d.slope_q, 0,
"flat heightmap must yield zero district slope"
);
assert_eq!(d.ocean_fraction_q, 0, "above sea level → no ocean");
}
#[test]
fn derive_district_no_radius_falls_back_to_direct_indexing() {
// body_radius_km = None (tiny test bodies): the district grid is the
// heightmap grid; derivation still succeeds and is deterministic.
let hm = test_hm();
let ta = test_ta(&hm);
let climate = ClimateConstants::default();
let p = BodyParams {
planet_class: Some("temperate".into()),
atmosphere: Some("breathable".into()),
..Default::default() // body_radius_km: None
};
let a = derive_district(test_seed(), &p, &ta, (20, 10), &climate);
let b = derive_district(test_seed(), &p, &ta, (20, 10), &climate);
assert_eq!(a.elev_q, b.elev_q);
assert!((0..=100).contains(&a.elev_q) && (0..=100).contains(&a.slope_q));
}
#[test]
fn derive_district_profile_is_deterministic() {
let hm = test_hm();