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
+162
View File
@@ -0,0 +1,162 @@
//! Detail-scatter synthesis (D-243 §2) — the mid-scale terrain (≈240 km) that
//! the coarse heightmap (~4078 km/pixel) is too coarse to carry and the voxel
//! generators (<2 km, inside a district) are too fine to reach. This is the
//! "stretch magic" that fills the gap so an interpolated heightmap reads as real
//! terrain instead of smooth ramps between samples.
//!
//! **Character (locked, T-1077):**
//! - **Adaptive** — ruggedness follows the local slope/morphology: gentle flats
//! stay soft (low amplitude, rolling), rugged highs gain ridge/valley energy.
//! - **Envelope + modulation** — the heightmap's local relief is the *ceiling* on
//! amplitude (smooth where the authored surface is smooth, so a lore body's
//! plain never sprouts a mountain), and morphology *modulates the character*
//! within that ceiling.
//!
//! **Determinism (D-227 / D-010):** a pure, seed+position-keyed value-noise
//! function — no stored state, no RNG, no platform-variant lookup. The output is
//! an `f64` *positional* perturbation; structural decisions downstream quantise it
//! to integer `slope_q`/`elev_q` (the truncate-before-decision convention).
use crate::seed::splitmix64;
/// Mid-scale octave wavelengths in metres — the 240 km band. Coarsest first.
/// Below the finest (~4 km) the district→voxel layers own the detail; above the
/// coarsest (~33 km) the heightmap itself carries the shape.
const OCTAVE_WAVELENGTHS_M: [f64; 4] = [32_768.0, 16_384.0, 8_192.0, 4_096.0];
/// Deterministic lattice value in `[-1, 1)` for an integer noise cell.
#[inline]
fn lattice(seed: u64, ix: i64, iy: i64) -> f64 {
let mixed = (ix as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)
^ (iy as u64).wrapping_mul(0xBF58_476D_1CE4_E5B9);
let h = splitmix64(seed ^ splitmix64(mixed));
// Top 53 bits → [0, 1) → [-1, 1).
((h >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
}
/// Smooth (C¹) value-noise sample in `[-1, 1]` at world `(wx, wy)` for one
/// wavelength. Smoothstep interpolation keeps lattice cell boundaries crease-free.
fn value_noise(seed: u64, wx: f64, wy: f64, wavelength_m: f64) -> f64 {
let fx = wx / wavelength_m;
let fy = wy / wavelength_m;
let (x0, y0) = (fx.floor(), fy.floor());
let (tx, ty) = (fx - x0, fy - y0);
let sx = tx * tx * (3.0 - 2.0 * tx);
let sy = ty * ty * (3.0 - 2.0 * ty);
let (ix, iy) = (x0 as i64, y0 as i64);
let c00 = lattice(seed, ix, iy);
let c10 = lattice(seed, ix + 1, iy);
let c01 = lattice(seed, ix, iy + 1);
let c11 = lattice(seed, ix + 1, iy + 1);
let a = c00 + (c10 - c00) * sx;
let b = c01 + (c11 - c01) * sx;
a + (b - a) * sy
}
/// The adaptive mid-scale elevation perturbation in `[0,1]`-normalized units (the
/// same space as `elev_pct`); multiply by the body's elevation span for metres.
///
/// - `wx`, `wy`: world-metre coordinates (absolute; keyed so features are
/// position-stable, never folded into a chunk frame).
/// - `envelope` (01): the heightmap's local relief — the **amplitude ceiling**.
/// - `ruggedness` (01): from local slope/morphology — **adaptive** ridge energy.
///
/// Returns roughly `[-envelope, +envelope]`, smaller and smoother as `ruggedness`
/// drops toward 0 (gentle flats), larger and ridged as it rises toward 1.
pub fn terrain_detail(seed: u64, wx: f64, wy: f64, envelope: f64, ruggedness: f64) -> f64 {
let env = envelope.clamp(0.0, 1.0);
let rug = ruggedness.clamp(0.0, 1.0);
if env == 0.0 {
return 0.0; // perfectly flat heightmap → no invented relief (envelope rule)
}
let mut sum = 0.0;
let mut amp = 1.0;
let mut norm = 0.0;
for (i, &wl) in OCTAVE_WAVELENGTHS_M.iter().enumerate() {
let mut n = value_noise(
seed.wrapping_add((i as u64).wrapping_mul(0x1000)),
wx,
wy,
wl,
);
// Ridged folding blends in with ruggedness: flats stay smooth (rolling
// fBm), highs fold to sharp ridges/incised valleys (modulation).
if rug > 0.0 {
let ridged = 1.0 - 2.0 * n.abs(); // ridge crest at zero-crossings
n = n * (1.0 - rug) + ridged * rug;
}
sum += n * amp;
norm += amp;
// Higher octaves gain weight in rugged zones (rough detail); flats damp
// them toward a single gentle swell.
amp *= 0.5 + 0.35 * rug;
}
let fbm = sum / norm; // ≈ [-1, 1]
// Envelope caps the amplitude; within the cap, ruggedness scales how much of
// the headroom is used (flats use ~40%, peaks ~100%).
fbm * env * (0.4 + 0.6 * rug)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deterministic() {
let a = terrain_detail(42, 12_345.0, -6_789.0, 0.6, 0.5);
let b = terrain_detail(42, 12_345.0, -6_789.0, 0.6, 0.5);
assert_eq!(a, b);
}
#[test]
fn flat_envelope_invents_nothing() {
// envelope = 0 → the authored heightmap is flat here → no relief (the
// envelope rule: never sprout terrain on an authored plain).
for &rug in &[0.0, 0.5, 1.0] {
assert_eq!(terrain_detail(7, 1000.0, 2000.0, 0.0, rug), 0.0);
}
}
#[test]
fn bounded_by_envelope() {
// Output magnitude never exceeds the envelope ceiling, anywhere.
for i in 0..400 {
let wx = (i as f64) * 137.0;
let wy = (i as f64) * -91.0;
let v = terrain_detail(99, wx, wy, 0.5, 1.0);
assert!(v.abs() <= 0.5 + 1e-9, "v={v} exceeded envelope at {i}");
}
}
#[test]
fn rugged_is_higher_amplitude_than_gentle() {
// Adaptive: averaged |relief| under high ruggedness exceeds that under low
// ruggedness for the same envelope (gentle flats vs rugged highs).
let mean_abs = |rug: f64| -> f64 {
let n = 500;
(0..n)
.map(|i| terrain_detail(3, i as f64 * 53.0, i as f64 * 71.0, 0.7, rug).abs())
.sum::<f64>()
/ n as f64
};
assert!(
mean_abs(0.9) > mean_abs(0.1),
"rugged terrain must carry more relief than gentle terrain"
);
}
#[test]
fn continuous_no_creases() {
// Small position steps produce small output changes (C¹ value noise) — no
// lattice creases that would read as grid artifacts.
let base = terrain_detail(11, 5_000.0, 5_000.0, 0.8, 0.6);
let near = terrain_detail(11, 5_000.5, 5_000.0, 0.8, 0.6);
assert!(
(base - near).abs() < 0.05,
"0.5 m step jumped by {}",
(base - near).abs()
);
}
}
+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, 0100) 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 01.
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 01 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();
+1
View File
@@ -10,6 +10,7 @@ pub mod body_world_state;
pub mod cascade;
pub mod chunk_context;
pub mod city_context_reader;
pub mod detail_scatter;
pub mod district_mix;
pub mod district_profile;
pub mod domain_warp;