T-1125: new atlas/coast_invention.rs — BodyCoastEnvelope (tier 1: erosion maturity derived hydro×atmo, tectonic energy; D-240 stands, no orbital inputs) + coast_character_at (tier 2: latitude, glaciation fjord response, local wetness, seeded 100-400 km heterogeneity field). C1 multi-octave warp ~16-262 km, sub-pixel cap 0.75 px, salted stream isolated from terrain scatter and climate edge-fuzz. district_profile: shared invent_primitives front-half (warped envelope sampling + slope-independent scatter floor + shoreline carving) wired into BOTH derive_district and derive_district_profile — the batch path's scatter-free area-mean aggregate is replaced by an invented centre-point sample of the same continuous field, making silent path divergence structurally impossible. T-1126: VegetationClass::Marine appended at discriminant 6, verdict morphology-derived (open_water from OpenOcean|Lake — no fourth ocean threshold); airless precedence preserved. voxel.rs exhaustive-match arm documented unreachable (WaterBody owns ocean districts). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
389 lines
17 KiB
Rust
389 lines
17 KiB
Rust
//! Coastline / terrain-detail invention character model (T-1125, D-227).
|
||
//!
|
||
//! The D-227 promise — terrain finer than the heightmap is *invented
|
||
//! deterministically* — ended at ~500 km until T-1125: district classification
|
||
//! consumed the raw bilinear envelope, so coasts were pixel-smooth and the
|
||
//! detail-scatter amplitude was slaved to the coarse heightmap slope (≈0 on
|
||
//! exactly the low-relief coasts where coastlines live). This module carries
|
||
//! the *character* half of the fix: **where the invention is jagged, where it
|
||
//! is smooth, and how strongly it perturbs**, per Jeroen's 2026-07-17 rulings:
|
||
//!
|
||
//! - **Crinkle varies.** Never one global roughness constant. Two driver tiers:
|
||
//! - **Tier 1 — body personality envelope** ([`BodyCoastEnvelope`]): derived
|
||
//! from the *existing* authored body params only — `planet_class` (via
|
||
//! `TectonicClass`), `hydrosphere`, `atmosphere`, `body_radius_km` via the
|
||
//! pixel⇄metre seam. D-240 stands: **no axial tilt, no orbital data**. No
|
||
//! erosion field exists — erosion-proneness is *derived*: more ocean → wetter,
|
||
//! rainier → higher erosion → smoother mature coasts; dry / thin-atmosphere →
|
||
//! sharp young coasts.
|
||
//! - **Tier 2 — position character** ([`coast_character_at`]): latitude,
|
||
//! driver-tier `GlaciationGrade` (high-latitude glaciated coasts go fjordy),
|
||
//! local wetness, and a seeded long-wavelength heterogeneity field
|
||
//! ([`character_field`], the T-1084 `voxel_mosaic` pattern at 100–400 km
|
||
//! octaves) so stretches of the *same* coast differ. Longitude participates
|
||
//! for free: everything is keyed on absolute world-metre coordinates.
|
||
//!
|
||
//! ## Determinism & continuity
|
||
//!
|
||
//! Pure functions of `(seed, body, position)` (D-227/D-010). The warp and the
|
||
//! character field are C¹ multi-octave value noise — character never steps on a
|
||
//! district/region line (the D-243 edge-fuzz discipline), and is identical for
|
||
//! any window size or derivation path that asks about the same world position.
|
||
//!
|
||
//! ## Distinct hash path
|
||
//!
|
||
//! The warp stream is salted ([`COAST_WARP_SALT`]) so it is never correlated
|
||
//! with the terrain detail-scatter or the climate edge-fuzz warp — the same
|
||
//! isolation convention as `region_profile::climate_edge_warp`.
|
||
|
||
use crate::atlas::detail_scatter::value_noise;
|
||
use crate::atlas::district_profile::{BodyParams, GlaciationGrade, TectonicClass};
|
||
use crate::seed::splitmix64;
|
||
|
||
/// Distinct hash-path salt for the coastline warp stream (see module docs).
|
||
const COAST_WARP_SALT: u64 = 0xC0A5_71E1_1BAD_5EED;
|
||
|
||
/// Salt separating the warp's y-channel from its x-channel.
|
||
const COAST_WARP_Y_SALT: u64 = 0xD1F7_0CEA_2B0A_D515;
|
||
|
||
/// Salt for the tier-2 heterogeneity field (same-coast stretches differ).
|
||
const CHARACTER_FIELD_SALT: u64 = 0x0C0A_57C4_A24C_7E12;
|
||
|
||
/// Coast-warp octave wavelengths in metres (≈16–262 km): capes and gulfs at the
|
||
/// top, coves and inlets at the bottom. All above the 4–33 km detail-scatter
|
||
/// band, and all below ~2 heightmap pixels — the warp perturbs the coast, it
|
||
/// does not rewrite continents (the heightmap stays the truth at its own scale).
|
||
const WARP_OCTAVE_WAVELENGTHS_M: [f64; 5] = [262_144.0, 131_072.0, 65_536.0, 32_768.0, 16_384.0];
|
||
|
||
/// Heterogeneity-field octave wavelengths in metres (≈100–400 km): the scale on
|
||
/// which one planet's coastline personality drifts from stretch to stretch.
|
||
const CHARACTER_OCTAVE_WAVELENGTHS_M: [f64; 3] = [409_600.0, 204_800.0, 102_400.0];
|
||
|
||
/// Hard cap on the coast-warp displacement, in working-grid pixel units. Keeps
|
||
/// the invention strictly sub-pixel so planetary-scale reads are unchanged.
|
||
const WARP_AMPLITUDE_CAP_PX: f64 = 0.75;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tier 1 — body personality envelope
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// The body-level coastline/relief personality (T-1125 tier 1) — one per body,
|
||
/// derived from authored [`BodyParams`] only (D-240 discipline; see module docs).
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub struct BodyCoastEnvelope {
|
||
/// 0–1 — Jeroen's erosion chain: `hydro_wetness × atmo_density`. Erosion
|
||
/// needs both water and an atmosphere to rain it back down. High = mature,
|
||
/// smoothed coasts; low = sharp young coasts.
|
||
pub erosion_maturity: f64,
|
||
/// 0–1 — from [`TectonicClass`] (planet_class-derived): relief energy.
|
||
pub tectonic_energy: f64,
|
||
/// Base octave-falloff roughness (0–1) before tier-2 modulation.
|
||
pub roughness: f64,
|
||
/// Base coast-warp amplitude in working-grid pixel units.
|
||
pub warp_amplitude_px: f64,
|
||
/// Base slope-independent detail-scatter floor, in `elev_pct` units (0–1).
|
||
pub scatter_floor: f64,
|
||
}
|
||
|
||
/// Derive the tier-1 envelope. `tectonic` is passed in (the caller hoists
|
||
/// `derive_tectonic_class`, which needs only `BodyParams`).
|
||
///
|
||
/// The `hydrosphere` buckets mirror `derive_moisture_q`'s T-1034 vocabulary
|
||
/// grouping — keep the two in sync when the DB vocabulary grows.
|
||
pub fn body_coast_envelope(params: &BodyParams, tectonic: TectonicClass) -> BodyCoastEnvelope {
|
||
let hydro_wetness: f64 = match params.hydrosphere.as_deref().unwrap_or("none") {
|
||
"liquid_water" | "ocean" | "ocean-coastal" | "extensive" => 1.0,
|
||
"rivers" | "rivers-lakes" | "moderate" => 0.65,
|
||
"ice" => 0.30,
|
||
"subsurface_liquid" => 0.20,
|
||
"subsurface" | "subsurface_ice" => 0.15,
|
||
"minimal" | "trace" => 0.10,
|
||
"none" => 0.0,
|
||
_ => 0.40,
|
||
};
|
||
let atmo_density: f64 = match params.atmosphere.as_deref().unwrap_or("none") {
|
||
"dense" => 1.0,
|
||
"standard" | "breathable" => 0.85,
|
||
"toxic" => 0.70,
|
||
"thin" => 0.35,
|
||
"none" => 0.0,
|
||
_ => 0.50,
|
||
};
|
||
let erosion_maturity = hydro_wetness * atmo_density;
|
||
let tectonic_energy: f64 = match tectonic {
|
||
TectonicClass::Volcanic => 1.0,
|
||
TectonicClass::Active | TectonicClass::TidallyForced => 0.70,
|
||
TectonicClass::Stable => 0.25,
|
||
};
|
||
BodyCoastEnvelope {
|
||
erosion_maturity,
|
||
tectonic_energy,
|
||
// Young (uneroded) + tectonic worlds read jagged; mature wet worlds smooth.
|
||
roughness: (0.20 + 0.50 * tectonic_energy + 0.35 * (1.0 - erosion_maturity))
|
||
.clamp(0.0, 1.0),
|
||
// Every body gets embayments; erosion widens/rounds them (rias, estuaries),
|
||
// tectonic energy adds displacement of its own. (Tuned against the T-1125
|
||
// zoom ladder: at ~0.2 px effective amplitude the w64 view still read as
|
||
// wavy stripes — bays need to displace a meaningful fraction of a pixel.)
|
||
warp_amplitude_px: 0.30 + 0.25 * erosion_maturity + 0.20 * tectonic_energy,
|
||
// Invented relief floor: young worlds carry more uneroded relief; even
|
||
// mature worlds keep gentle rolling texture (eroded ≠ billiard-flat).
|
||
scatter_floor: 0.10 + 0.15 * tectonic_energy + 0.05 * (1.0 - erosion_maturity),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tier 2 — position character
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// The resolved invention character at one world position (T-1125 tier 2).
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub struct CoastCharacter {
|
||
/// Coast-warp displacement amplitude, working-grid pixel units (≤ cap).
|
||
pub warp_amplitude_px: f64,
|
||
/// 0–1 octave falloff: high keeps small-wavelength energy (jagged).
|
||
pub roughness: f64,
|
||
/// Slope-independent detail-scatter floor (`elev_pct` units).
|
||
pub scatter_floor: f64,
|
||
/// 0–1 ridged-folding blend for the scatter (fjord/tectonic sharpness).
|
||
pub ridge: f64,
|
||
}
|
||
|
||
/// Resolve the invention character at `(wx, wy)` world metres.
|
||
///
|
||
/// `driver_glaciation` / `driver_moisture_q` are the **one-step-stale** climate
|
||
/// drivers computed from the *unwarped* raw-bilinear primitives (the T-1125
|
||
/// circularity ruling — see the call site in `district_profile`).
|
||
pub fn coast_character_at(
|
||
env: &BodyCoastEnvelope,
|
||
seed: u64,
|
||
wx: f64,
|
||
wy: f64,
|
||
lat_deg: f64,
|
||
driver_glaciation: GlaciationGrade,
|
||
driver_moisture_q: i32,
|
||
) -> CoastCharacter {
|
||
// Seeded heterogeneity in [0,1]: same-coast stretches differ (Jeroen).
|
||
let hetero = character_field(seed, wx, wy);
|
||
let fjord: f64 = match driver_glaciation {
|
||
GlaciationGrade::None => 0.0,
|
||
GlaciationGrade::Light => 0.30,
|
||
GlaciationGrade::Moderate => 0.70,
|
||
GlaciationGrade::Heavy => 1.0,
|
||
// Under a permanent cap the fjord carving is there but partly buried.
|
||
GlaciationGrade::IceCap => 0.90,
|
||
};
|
||
let lat_frac = (lat_deg.abs() / 90.0).clamp(0.0, 1.0);
|
||
// Local wetness echoes the tier-1 erosion chain at position scale: the wet
|
||
// stretches of a body erode smoother than its dry stretches.
|
||
let wet_local = (driver_moisture_q.clamp(0, 100) as f64) / 100.0;
|
||
|
||
CoastCharacter {
|
||
warp_amplitude_px: (env.warp_amplitude_px * (0.75 + 0.60 * hetero) * (1.0 + 0.50 * fjord))
|
||
.min(WARP_AMPLITUDE_CAP_PX),
|
||
roughness: (env.roughness + 0.35 * fjord + 0.10 * lat_frac + 0.25 * (hetero - 0.5)
|
||
- 0.15 * wet_local)
|
||
.clamp(0.05, 1.0),
|
||
scatter_floor: (env.scatter_floor * (0.70 + 0.60 * hetero) + 0.10 * fjord).clamp(0.0, 0.40),
|
||
ridge: (0.15 + 0.55 * fjord + 0.35 * env.tectonic_energy).clamp(0.0, 1.0),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Fields
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Long-wavelength heterogeneity field in `[0, 1]` (the T-1084 `voxel_mosaic`
|
||
/// pattern at coast scale): drives *where along a coast* the character shifts.
|
||
pub fn character_field(seed: u64, wx: f64, wy: f64) -> f64 {
|
||
let seed = splitmix64(seed ^ CHARACTER_FIELD_SALT);
|
||
let mut sum = 0.0;
|
||
let mut amp = 1.0;
|
||
let mut norm = 0.0;
|
||
for (i, &wl) in CHARACTER_OCTAVE_WAVELENGTHS_M.iter().enumerate() {
|
||
sum += value_noise(
|
||
seed.wrapping_add((i as u64).wrapping_mul(0x1000)),
|
||
wx,
|
||
wy,
|
||
wl,
|
||
) * amp;
|
||
norm += amp;
|
||
amp *= 0.5;
|
||
}
|
||
(((sum / norm) + 1.0) * 0.5).clamp(0.0, 1.0)
|
||
}
|
||
|
||
/// The coastline domain-warp displacement in **working-grid pixel units**.
|
||
///
|
||
/// Two independent C¹ fBm channels (x/y) on the salted stream, shaped by the
|
||
/// position character: `roughness` controls octave falloff (mature coasts keep
|
||
/// only the broad bays; young/fjordy coasts keep the jagged inlets) and `ridge`
|
||
/// blends ridged folding in (crease-cornered incursions — fjord arms), exactly
|
||
/// the `detail_scatter::enveloped_fbm` modulation convention.
|
||
///
|
||
/// Apply the same offset to *every* envelope field sampled at the position
|
||
/// (elevation, slope, ocean mask) so the invented terrain moves coherently —
|
||
/// a warped-in bay carries its sea-level elevation with it.
|
||
pub fn coast_warp_px(seed: u64, wx: f64, wy: f64, ch: &CoastCharacter) -> (f64, f64) {
|
||
let sx = splitmix64(seed ^ COAST_WARP_SALT);
|
||
let sy = splitmix64(sx ^ COAST_WARP_Y_SALT);
|
||
(
|
||
warp_fbm(sx, wx, wy, ch) * ch.warp_amplitude_px,
|
||
warp_fbm(sy, wx, wy, ch) * ch.warp_amplitude_px,
|
||
)
|
||
}
|
||
|
||
/// Roughness/ridge-shaped fBm in ≈`[-1, 1]` over the coast-warp octave band.
|
||
fn warp_fbm(seed: u64, wx: f64, wy: f64, ch: &CoastCharacter) -> f64 {
|
||
let mut sum = 0.0;
|
||
let mut amp = 1.0;
|
||
let mut norm = 0.0;
|
||
for (i, &wl) in WARP_OCTAVE_WAVELENGTHS_M.iter().enumerate() {
|
||
let mut n = value_noise(
|
||
seed.wrapping_add((i as u64).wrapping_mul(0x1000)),
|
||
wx,
|
||
wy,
|
||
wl,
|
||
);
|
||
if ch.ridge > 0.0 {
|
||
let ridged = 1.0 - 2.0 * n.abs();
|
||
n = n * (1.0 - ch.ridge) + ridged * ch.ridge;
|
||
}
|
||
sum += n * amp;
|
||
norm += amp;
|
||
// Falloff: smooth coasts damp high frequencies (0.45 — rounded rias,
|
||
// never featureless); jagged coasts keep them (0.80). Tuned on the
|
||
// ladder: at 0.35 base a mature coast lost its 16–65 km headlands
|
||
// entirely and still read as stripes at w64.
|
||
amp *= 0.45 + 0.35 * ch.roughness;
|
||
}
|
||
sum / norm
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn params(hydro: &str, atmo: &str, class: &str) -> BodyParams {
|
||
BodyParams {
|
||
hydrosphere: Some(hydro.to_string()),
|
||
atmosphere: Some(atmo.to_string()),
|
||
planet_class: Some(class.to_string()),
|
||
tectonic_activity: None,
|
||
latitude_deg: 0.0,
|
||
elevation_km: 0.0,
|
||
body_radius_km: Some(6000.0),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn envelope_differentiates_wet_from_dry_bodies() {
|
||
// Jeroen's erosion chain: ocean+breathable erodes smooth; dry+thin stays sharp.
|
||
let wet = body_coast_envelope(
|
||
¶ms("ocean", "breathable", "temperate"),
|
||
TectonicClass::Stable,
|
||
);
|
||
let dry = body_coast_envelope(¶ms("minimal", "thin", "arid"), TectonicClass::Stable);
|
||
assert!(wet.erosion_maturity > dry.erosion_maturity);
|
||
assert!(
|
||
wet.roughness < dry.roughness,
|
||
"mature coasts must be smoother"
|
||
);
|
||
assert!(
|
||
wet.warp_amplitude_px > dry.warp_amplitude_px,
|
||
"erosion widens embayments (rias) — wet worlds warp broader"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn envelope_tectonics_raise_roughness_and_relief_floor() {
|
||
let stable = body_coast_envelope(
|
||
¶ms("ocean", "breathable", "temperate"),
|
||
TectonicClass::Stable,
|
||
);
|
||
let volcanic = body_coast_envelope(
|
||
¶ms("ocean", "breathable", "volcanic"),
|
||
TectonicClass::Volcanic,
|
||
);
|
||
assert!(volcanic.roughness > stable.roughness);
|
||
assert!(volcanic.scatter_floor > stable.scatter_floor);
|
||
}
|
||
|
||
#[test]
|
||
fn character_fjord_raises_roughness_amplitude_and_ridge() {
|
||
let env = body_coast_envelope(¶ms("ice", "thin", "frozen"), TectonicClass::Stable);
|
||
let temperate = coast_character_at(&env, 42, 1e6, 2e6, 40.0, GlaciationGrade::None, 40);
|
||
let fjordy = coast_character_at(&env, 42, 1e6, 2e6, 70.0, GlaciationGrade::Heavy, 40);
|
||
assert!(fjordy.roughness > temperate.roughness);
|
||
assert!(fjordy.warp_amplitude_px > temperate.warp_amplitude_px);
|
||
assert!(fjordy.ridge > temperate.ridge);
|
||
}
|
||
|
||
#[test]
|
||
fn character_heterogeneity_varies_along_a_coast() {
|
||
// Same body, same latitude, positions ~600 km apart: the seeded field must
|
||
// move the character so same-coast stretches differ (Jeroen's ruling).
|
||
let env = body_coast_envelope(
|
||
¶ms("ocean", "breathable", "temperate"),
|
||
TectonicClass::Stable,
|
||
);
|
||
let chars: Vec<f64> = (0..8)
|
||
.map(|i| {
|
||
coast_character_at(
|
||
&env,
|
||
7,
|
||
i as f64 * 600_000.0,
|
||
500_000.0,
|
||
30.0,
|
||
GlaciationGrade::None,
|
||
50,
|
||
)
|
||
.warp_amplitude_px
|
||
})
|
||
.collect();
|
||
let min = chars.iter().cloned().fold(f64::INFINITY, f64::min);
|
||
let max = chars.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||
assert!(
|
||
max - min > 0.02,
|
||
"character must drift along the coast (got range {})",
|
||
max - min
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn warp_is_deterministic_bounded_and_continuous() {
|
||
let env = body_coast_envelope(
|
||
¶ms("ocean", "breathable", "temperate"),
|
||
TectonicClass::Stable,
|
||
);
|
||
let ch = coast_character_at(&env, 42, 3e6, 1e6, 30.0, GlaciationGrade::Light, 55);
|
||
let a = coast_warp_px(42, 3e6, 1e6, &ch);
|
||
let b = coast_warp_px(42, 3e6, 1e6, &ch);
|
||
assert_eq!(a, b, "warp must be deterministic");
|
||
for i in 0..400 {
|
||
let (dx, dy) = coast_warp_px(42, i as f64 * 9_137.0, i as f64 * -7_211.0, &ch);
|
||
assert!(dx.abs() <= WARP_AMPLITUDE_CAP_PX + 1e-9);
|
||
assert!(dy.abs() <= WARP_AMPLITUDE_CAP_PX + 1e-9);
|
||
}
|
||
// C¹ continuity: a 10 m step is a tiny displacement change — the coast
|
||
// character never steps on a line (D-243 edge-fuzz discipline).
|
||
let (x0, y0) = coast_warp_px(42, 5e6, 5e6, &ch);
|
||
let (x1, y1) = coast_warp_px(42, 5e6 + 10.0, 5e6, &ch);
|
||
assert!((x0 - x1).abs() < 0.01 && (y0 - y1).abs() < 0.01);
|
||
}
|
||
|
||
#[test]
|
||
fn warp_stream_uncorrelated_with_scatter_stream() {
|
||
// Distinct hash path: the warp at a position must not track the terrain
|
||
// detail-scatter at the same position (salted stream isolation).
|
||
let env = body_coast_envelope(
|
||
¶ms("ocean", "breathable", "temperate"),
|
||
TectonicClass::Stable,
|
||
);
|
||
let ch = coast_character_at(&env, 42, 1e6, 1e6, 20.0, GlaciationGrade::None, 60);
|
||
let (wdx, _) = coast_warp_px(42, 1e6, 1e6, &ch);
|
||
let scatter = crate::atlas::detail_scatter::terrain_detail(42, 1e6, 1e6, 1.0, 0.5);
|
||
assert_ne!(wdx, scatter);
|
||
}
|
||
}
|