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:
@@ -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(®ion_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, ¶ms, 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();
|
||||
|
||||
Reference in New Issue
Block a user