The fresh descent ladder (first since T-1240) showed Region carrying a straight diagonal band of blocky, grid-locked stair-steps, and a scatter of stray ValleyFloor cells. D-258 (1) says a biome edge is a gradient, never a line. Two separate causes, each traced to a plane before it was fixed: 1. THE SEAM — the coast/terrain character read the CATEGORICAL glaciation grade. It flips None -> Light on the 6 C isotherm, `fjord` jumped 0 -> 0.30, and ridge, scatter floor and warp amplitude jumped with it. Dumping the Region canvas planes put the seam in relief_q itself, not the paint; zeroing that one step removed it. The character now reads a continuous `glacial_weight` — linear through the grade bands' centres, where it agrees with the old per-grade weights, and a ramped moisture gate. The grade stays the classification; only the character stopped stepping. 2. THE SPECKLE — composition's rare inclusions ignored the rung's octave cutoff. At 379 m cells the 192 m inclusion field was sampled below Nyquist: instead of vanishing (what the constant's doc promised) it aliased, a +25 slope push scattered at random, tipping 8,453 cells into slope-gated ValleyFloor. Now gated like every other invented octave: ValleyFloor at Region 8,453 -> 0, and District/Quarter captures are byte-identical, since they resolve inclusions. Region, lum p1-p99 63.45 -> 63.60 (structure kept), distinct colours 26,701 -> 2,102 (noise removed). THE CONSERVATION GATE was `#[ignore]`d and did not compare anything: its doc said it derived with and without composition, but it derived once and checked some class held >50%, which a forest patch turned wholly to scrub passes. Now every patch is derived twice through the same core (a new `invent_composition` switch on the private derive; both production paths pass true), the composed plurality must EQUAL the uncomposed one, across 13 patches spread over the body, with the judged count and "anything changed at all" asserted so it cannot pass vacuously. It runs in plain `cargo test` (~4 s debug). Mutation-proven both ways: inclusions over most ground fails it by name (Scrub 90% -> Barren 52%), composition switched off fails it on "the fine tier is off". Goldens: every window-derivation change is an inclusion that a 4,096 or 1,024 m cutoff can no longer receive (e.g. slope 27 -> 2, ValleyFloor -> AlluvialPlain); believability moved one point on two contrast figures. Version 0.4.15: derived bytes change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
683 lines
28 KiB
Rust
683 lines
28 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 glacial weight (high-latitude glaciated coasts go fjordy —
|
||
//! a continuous ramp over the grade bands, [`glacial_weight`], so the
|
||
//! character never steps on an isotherm either),
|
||
//! 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 (≈1,024 m–262 km): capes and gulfs
|
||
/// at the top, down through bays/headlands/islets at the bottom (T-1162 — the
|
||
/// headline "detailed coastline" visual ask). The warp perturbs the coast, it
|
||
/// does not rewrite continents (the heightmap stays the truth at its own
|
||
/// scale) — displacing the sampling *position*, distinct from
|
||
/// `detail_scatter::terrain_detail`'s independent perturbation of the sampled
|
||
/// *elevation*.
|
||
///
|
||
/// **T-1162 extension:** four octaves finer than the pre-T-1162 floor
|
||
/// (16,384 m) were appended — 8,192 / 4,096 / 2,048 / 1,024 — reaching the
|
||
/// Quarter rung's own Nyquist floor (512 m spacing → content wavelength ≥ 2×
|
||
/// spacing = 1,024 m; finer than that is unsampleable noise at Quarter
|
||
/// density, not detail). A Quarter-rung request (`min_wavelength_m` cutoff
|
||
/// quantized to 1,024 m, see `layer_proxy::MIN_WL_BANDS_M`) admits every
|
||
/// octave down to this floor; a District-rung request (cutoff quantized to
|
||
/// District's OWN real band, `2 × DISTRICT_M = 4,096` m — District's own
|
||
/// Nyquist limit, derived independently from the rung's spacing per PR #194
|
||
/// I1, NOT from any invention field's octave array) truncates the two
|
||
/// finest additions (2,048/1,024 m — genuinely below District's floor)
|
||
/// while legitimately ADMITTING the other two (8,192/4,096 m — new content
|
||
/// that IS coarse enough for District to resolve, not a leak). Either way
|
||
/// Quarter still sees strictly more than District at the SAME position (the
|
||
/// two finest additions are Quarter-exclusive). The
|
||
/// `enveloped_fbm`-style hard-truncate cutoff discipline is honored via
|
||
/// [`warp_fbm`]'s own `min_wavelength_m` parameter — `0.0` (no cutoff) is
|
||
/// byte-identical to every pre-T-1162 caller.
|
||
const WARP_OCTAVE_WAVELENGTHS_M: [f64; 9] = [
|
||
262_144.0, 131_072.0, 65_536.0, 32_768.0, 16_384.0, 8_192.0, 4_096.0, 2_048.0, 1_024.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,
|
||
}
|
||
|
||
/// The fjord weight of each glaciation grade — how strongly glacial carving
|
||
/// shapes the invention character. Kept as the grade-level statement of the
|
||
/// ramp [`glacial_weight`] interpolates; [`glacial_weight`] agrees with it at
|
||
/// each band's centre.
|
||
pub fn grade_glacial_weight(grade: GlaciationGrade) -> f64 {
|
||
match grade {
|
||
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,
|
||
}
|
||
}
|
||
|
||
/// `(temperature °C, weight)` knots of the continuous glacial ramp: the warm
|
||
/// edge of Light, then the centre of each grade's band in
|
||
/// `derive_glaciation_grade_from_climate` (Light −4..5, Moderate −14..−5,
|
||
/// Heavy −29..−15, IceCap ≤ −30). Linear between knots, flat beyond the ends.
|
||
const GLACIAL_KNOTS: [(f64, f64); 5] = [
|
||
(6.0, 0.0),
|
||
(0.5, 0.30),
|
||
(-9.5, 0.70),
|
||
(-22.0, 1.0),
|
||
(-34.0, 0.90),
|
||
];
|
||
|
||
/// Continuous glacial weight in `[0, 1]` from the driver climate (T-1213).
|
||
///
|
||
/// This replaced reading the CATEGORICAL grade, which made the character step:
|
||
/// the grade flips None → Light on an isotherm, `fjord` jumped 0 → 0.30, and
|
||
/// ridge, scatter floor and warp amplitude all jumped with it. On Ferrath's
|
||
/// descent-ladder Region that isotherm crossed the canvas as a straight seam in
|
||
/// the relief field, which the hillshade drew as a stair-stepped cliff — a
|
||
/// biome edge falling on a line, which D-258 (1) and the D-243 edge-fuzz rule
|
||
/// both forbid. The grade stays the classification; only the character's
|
||
/// dependence on it is made continuous.
|
||
///
|
||
/// The moisture gate (no ice below `moisture_q` 10) is ramped over 5..15 for
|
||
/// the same reason. Airless bodies carry no glacial weight (ice is geology).
|
||
pub fn glacial_weight(temperature_c: Option<f32>, moisture_q: i32) -> f64 {
|
||
let Some(t) = temperature_c else {
|
||
return 0.0;
|
||
};
|
||
let t = t as f64;
|
||
let first = GLACIAL_KNOTS[0];
|
||
let last = GLACIAL_KNOTS[GLACIAL_KNOTS.len() - 1];
|
||
let temp_weight = if t >= first.0 {
|
||
first.1
|
||
} else if t <= last.0 {
|
||
last.1
|
||
} else {
|
||
GLACIAL_KNOTS
|
||
.windows(2)
|
||
.find(|w| t <= w[0].0 && t >= w[1].0)
|
||
.map(|w| {
|
||
let f = (w[0].0 - t) / (w[0].0 - w[1].0);
|
||
w[0].1 + f * (w[1].1 - w[0].1)
|
||
})
|
||
.unwrap_or(0.0)
|
||
};
|
||
let moisture_gate = ((moisture_q as f64 - 5.0) / 10.0).clamp(0.0, 1.0);
|
||
temp_weight * moisture_gate
|
||
}
|
||
|
||
/// Resolve the invention character at `(wx, wy)` world metres.
|
||
///
|
||
/// `fjord` (0–1, see [`glacial_weight`]) and `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,
|
||
fjord: f64,
|
||
driver_moisture_q: i32,
|
||
) -> CoastCharacter {
|
||
// Seeded heterogeneity in [0,1]: same-coast stretches differ (Jeroen).
|
||
let hetero = character_field(seed, wx, wy);
|
||
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.
|
||
///
|
||
/// `min_wavelength_m` (T-1162, mirrors `detail_scatter::terrain_detail`'s
|
||
/// contract exactly): octaves in [`WARP_OCTAVE_WAVELENGTHS_M`] finer than this
|
||
/// cutoff are hard-truncated, never faded. `0.0` = no cutoff = every octave —
|
||
/// byte-identical to every pre-T-1162 caller (all of which passed no cutoff at
|
||
/// all, since the parameter didn't exist before this ticket).
|
||
pub fn coast_warp_px(
|
||
seed: u64,
|
||
wx: f64,
|
||
wy: f64,
|
||
ch: &CoastCharacter,
|
||
min_wavelength_m: f64,
|
||
) -> (f64, f64) {
|
||
let sx = splitmix64(seed ^ COAST_WARP_SALT);
|
||
let sy = splitmix64(sx ^ COAST_WARP_Y_SALT);
|
||
(
|
||
warp_fbm(sx, wx, wy, ch, min_wavelength_m) * ch.warp_amplitude_px,
|
||
warp_fbm(sy, wx, wy, ch, min_wavelength_m) * ch.warp_amplitude_px,
|
||
)
|
||
}
|
||
|
||
/// Roughness/ridge-shaped fBm in ≈`[-1, 1]` over the coast-warp octave band.
|
||
///
|
||
/// `min_wavelength_m` (T-1162): same hard-truncate discipline as
|
||
/// `detail_scatter::enveloped_fbm` — octaves with `wl < min_wavelength_m` are
|
||
/// skipped entirely, but `amp` still advances through the skipped step so
|
||
/// surviving octaves keep their intended relative weight (no renormalization
|
||
/// against a smaller octave count beyond the shared `norm` denominator, which
|
||
/// already only sums the terms that ran).
|
||
fn warp_fbm(seed: u64, wx: f64, wy: f64, ch: &CoastCharacter, min_wavelength_m: f64) -> 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() {
|
||
if wl < min_wavelength_m {
|
||
amp *= 0.45 + 0.35 * ch.roughness;
|
||
continue;
|
||
}
|
||
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;
|
||
}
|
||
if norm == 0.0 {
|
||
return 0.0; // every octave cut by the cutoff → no warp displacement left
|
||
}
|
||
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,
|
||
grade_glacial_weight(GlaciationGrade::None),
|
||
40,
|
||
);
|
||
let fjordy = coast_character_at(
|
||
&env,
|
||
42,
|
||
1e6,
|
||
2e6,
|
||
70.0,
|
||
grade_glacial_weight(GlaciationGrade::Heavy),
|
||
40,
|
||
);
|
||
assert!(fjordy.roughness > temperate.roughness);
|
||
assert!(fjordy.warp_amplitude_px > temperate.warp_amplitude_px);
|
||
assert!(fjordy.ridge > temperate.ridge);
|
||
}
|
||
|
||
/// The ramp says what the grades said, where the grades are unambiguous:
|
||
/// at each band's centre it returns that grade's weight.
|
||
#[test]
|
||
fn glacial_weight_agrees_with_the_grade_at_band_centres() {
|
||
use crate::atlas::district_profile::derive_glaciation_grade_from_climate;
|
||
for t in [20.0f32, 0.5, -9.5, -22.0, -40.0] {
|
||
let grade = derive_glaciation_grade_from_climate(Some(t), 60);
|
||
assert!(
|
||
(glacial_weight(Some(t), 60) - grade_glacial_weight(grade)).abs() < 1e-9,
|
||
"at {t} °C the ramp disagrees with {grade:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The reason the ramp exists: no step anywhere. The categorical version
|
||
/// jumped 0.30 across one tenth of a degree at 6 °C.
|
||
#[test]
|
||
fn glacial_weight_never_steps() {
|
||
let mut prev = glacial_weight(Some(30.0), 60);
|
||
for i in 1..=800 {
|
||
let t = 30.0 - i as f32 * 0.1;
|
||
let w = glacial_weight(Some(t), 60);
|
||
assert!(
|
||
(w - prev).abs() < 0.01,
|
||
"glacial weight jumped {prev:.3} -> {w:.3} at {t} °C"
|
||
);
|
||
prev = w;
|
||
}
|
||
for m in 0..100 {
|
||
let a = glacial_weight(Some(-10.0), m);
|
||
let b = glacial_weight(Some(-10.0), m + 1);
|
||
// One moisture point is a tenth of the 5..15 ramp, at most 0.1 of weight.
|
||
assert!((a - b).abs() <= 0.1 + 1e-9, "moisture gate stepped at {m}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn glacial_weight_is_zero_when_airless_or_dry() {
|
||
assert_eq!(glacial_weight(None, 80), 0.0);
|
||
assert_eq!(glacial_weight(Some(-25.0), 3), 0.0);
|
||
}
|
||
|
||
#[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,
|
||
grade_glacial_weight(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,
|
||
grade_glacial_weight(GlaciationGrade::Light),
|
||
55,
|
||
);
|
||
let a = coast_warp_px(42, 3e6, 1e6, &ch, 0.0);
|
||
let b = coast_warp_px(42, 3e6, 1e6, &ch, 0.0);
|
||
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, 0.0);
|
||
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, 0.0);
|
||
let (x1, y1) = coast_warp_px(42, 5e6 + 10.0, 5e6, &ch, 0.0);
|
||
assert!((x0 - x1).abs() < 0.01 && (y0 - y1).abs() < 0.01);
|
||
}
|
||
|
||
// ── min_wavelength_m cutoff (T-1162) ──────────────────────────────────
|
||
|
||
#[test]
|
||
fn cutoff_zero_matches_pre_t1162_behavior() {
|
||
// 0.0 = no cutoff = every octave — the compatibility contract every
|
||
// pre-T-1162 caller relies on (all of which never had a cutoff param
|
||
// to begin with).
|
||
let env = body_coast_envelope(
|
||
¶ms("ocean", "breathable", "temperate"),
|
||
TectonicClass::Stable,
|
||
);
|
||
let ch = coast_character_at(
|
||
&env,
|
||
42,
|
||
3e6,
|
||
1e6,
|
||
30.0,
|
||
grade_glacial_weight(GlaciationGrade::Light),
|
||
55,
|
||
);
|
||
for i in 0..100 {
|
||
let wx = i as f64 * 9_137.0;
|
||
let wy = i as f64 * -7_211.0;
|
||
let with_zero = coast_warp_px(42, wx, wy, &ch, 0.0);
|
||
// The finest WARP_OCTAVE_WAVELENGTHS_M entry is 1,024.0 — a cutoff
|
||
// below that admits every octave too, and must agree exactly.
|
||
let with_below_finest = coast_warp_px(42, wx, wy, &ch, 1.0);
|
||
assert_eq!(with_zero, with_below_finest);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn cutoff_truncates_octaves_below_it() {
|
||
// A cutoff above the coarsest WARP_OCTAVE_WAVELENGTHS_M entry
|
||
// (262,144.0) must skip every octave and fall back to (0.0, 0.0) (the
|
||
// norm==0.0 empty-sum guard).
|
||
let env = body_coast_envelope(
|
||
¶ms("ocean", "breathable", "temperate"),
|
||
TectonicClass::Stable,
|
||
);
|
||
let ch = coast_character_at(
|
||
&env,
|
||
42,
|
||
3e6,
|
||
1e6,
|
||
30.0,
|
||
grade_glacial_weight(GlaciationGrade::Light),
|
||
55,
|
||
);
|
||
let (dx, dy) = coast_warp_px(42, 1_000.0, 2_000.0, &ch, 1_000_000.0);
|
||
assert_eq!((dx, dy), (0.0, 0.0));
|
||
}
|
||
|
||
#[test]
|
||
fn cutoff_changes_output_relative_to_uncut() {
|
||
// A mid-band cutoff (drops the four finest octaves: 8192/4096/2048/1024)
|
||
// must produce DIFFERENT output than the uncut warp at the same
|
||
// position — otherwise the T-1162 extension would be inert.
|
||
let env = body_coast_envelope(
|
||
¶ms("ocean", "breathable", "temperate"),
|
||
TectonicClass::Stable,
|
||
);
|
||
let ch = coast_character_at(
|
||
&env,
|
||
17,
|
||
1.2e6,
|
||
9e5,
|
||
25.0,
|
||
grade_glacial_weight(GlaciationGrade::None),
|
||
60,
|
||
);
|
||
let uncut = coast_warp_px(17, 1.2e6, 9e5, &ch, 0.0);
|
||
let cut = coast_warp_px(17, 1.2e6, 9e5, &ch, 16_385.0);
|
||
assert_ne!(
|
||
uncut, cut,
|
||
"a mid-band cutoff must change the derived warp output"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn district_cutoff_excludes_quarter_only_octaves() {
|
||
// T-1162 discipline: District's REAL quantized `MIN_WL_BANDS_M`
|
||
// floor is 4,096 m (District's own Nyquist limit — matches
|
||
// `terrain_detail`'s pre-existing finest octave; see
|
||
// `layer_proxy::MIN_WL_BANDS_M`'s doc). At that cutoff, the coast
|
||
// warp's two finest additions (2,048/1,024 m) are excluded — only
|
||
// Quarter's cutoff (1,024 m) admits them. This is the positive proof
|
||
// that extending the octave floor gives Quarter genuinely more than
|
||
// District at the SAME world position.
|
||
let env = body_coast_envelope(
|
||
¶ms("ocean", "breathable", "temperate"),
|
||
TectonicClass::Stable,
|
||
);
|
||
let ch = coast_character_at(
|
||
&env,
|
||
5,
|
||
2.5e6,
|
||
1.5e6,
|
||
15.0,
|
||
grade_glacial_weight(GlaciationGrade::None),
|
||
45,
|
||
);
|
||
let district = coast_warp_px(5, 2.5e6, 1.5e6, &ch, 4_096.0);
|
||
let quarter = coast_warp_px(5, 2.5e6, 1.5e6, &ch, 1_024.0);
|
||
assert_ne!(
|
||
district, quarter,
|
||
"Quarter's finer cutoff must admit octaves District's cutoff excludes"
|
||
);
|
||
}
|
||
|
||
#[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,
|
||
grade_glacial_weight(GlaciationGrade::None),
|
||
60,
|
||
);
|
||
let (wdx, _) = coast_warp_px(42, 1e6, 1e6, &ch, 0.0);
|
||
let scatter = crate::atlas::detail_scatter::terrain_detail(42, 1e6, 1e6, 1.0, 0.5, 0.0);
|
||
assert_ne!(wdx, scatter);
|
||
}
|
||
}
|