Files
settled-reach/server/src/atlas/coast_invention.rs
T
jpmschweitzerandClaude Fable 5 fef422bf01 feat(simulation): T-1125/T-1126 coastline invention — two-tier character model, shared invent_primitives, Marine vegetation
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>
2026-07-17 08:21:07 +02:00

389 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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(
&params("ocean", "breathable", "temperate"),
TectonicClass::Stable,
);
let dry = body_coast_envelope(&params("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(
&params("ocean", "breathable", "temperate"),
TectonicClass::Stable,
);
let volcanic = body_coast_envelope(
&params("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(&params("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(
&params("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(
&params("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(
&params("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);
}
}