Merge remote-tracking branch 'origin/coastline-invention'
This commit is contained in:
@@ -1618,6 +1618,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
- **Rationale:** A world that stores its tiles cannot scale to body-sized 3-D volumes and bloats saves; a pure-function world with a transient cache + a sparse mutator log scales to any size, makes saves trivially small, and is the only model under which "dig anywhere, to any depth" is free (the subsurface was always computable — digging just reveals it). It also forces the determinism discipline the whole cascade needs anyway. The downward floor cap fell because it was solving a problem — per-layer storage cost — that derive-don't-store eliminates.
|
||||
- **Open sub-questions:** the geology-model fidelity (simple depth-horizon stack vs tectonic-grade folding/faults) and how far `FloorMaterial` is derived now vs deferred to the city layers (both tracked in D-228 / Q-101); the mutator op schema (Q-103).
|
||||
- **Implementation:** Phase 4+ (epic T-750). The caching substrate exists at the atlas level (`BodyWorldStateCache`, D-203/D-225); the tile/voxel cache, mutator overlay, and geology derivation land as the cascade reaches the tile layers. No migration needed pre-save (D-202 `schema_version` lineage covers future drift once saves exist).
|
||||
- **Amended 2026-07-17 (T-1125 — the invention carries geographic content into district classification; two-tier driver model):** the "invented deterministically (interpolation + domain warp + detail-scatter)" clause is now implemented *with character* at the district tier, closing the T-1123 finding that classification consumed only the raw bilinear envelope (pixel-smooth coasts; scatter amplitude slaved to coarse heightmap slope ≈ 0 exactly on low-relief coasts). Mechanism (`atlas/coast_invention.rs` + `district_profile::invent_primitives`, shared by the on-demand `derive_district` AND batch `derive_district_profile` paths so they can never silently diverge): **(a) coastline domain-warp** — every envelope field (elevation, slope, ocean mask) is bilinearly sampled at the same warp-displaced position (C¹ multi-octave value noise, ≈16–262 km band, sub-pixel amplitude cap 0.75 px, distinct salted hash stream — never correlated with the terrain scatter or climate edge-fuzz), inventing bays/capes/fjord inlets while the heightmap stays the truth at its own scale; **(b) slope-independent scatter floor** — the detail-scatter envelope gains a character-driven floor (invention no longer collapses on flat coasts; the old "flat envelope → zero invention" reading is superseded — the envelope rule survives as a *ceiling*: gentle bounded relief, never mountains on an authored plain); **(c) shoreline carving** — ridged character contributes real slope in shoreline patches so the steep coastal families (Fjord/CliffCoast) can fire where glacially/tectonically justified. **Crinkle varies (Jeroen's ruling): two driver tiers, zero new authored data.** Tier 1 (body personality envelope): `planet_class` (via `TectonicClass`) + `hydrosphere` + `atmosphere` + `body_radius_km` (via the pixel⇄metre seam) only — **D-240 stands: no orbital/tilt inputs**; erosion-proneness is *derived* (more ocean → wetter/rainier → higher erosion → smoother mature coasts; dry/thin-atmosphere → sharp young coasts). Tier 2 (position): latitude, driver-tier `GlaciationGrade` (fjordy high-latitude glaciated coasts), local wetness, and a seeded ~100–400 km heterogeneity field so stretches of the same coast differ; longitude participates via absolute world-metre noise keying. **Circularity ruling:** the driver-tier climate (glaciation/moisture) reads the *unwarped* raw-bilinear primitives — one-step-stale by design, documented at the call site. All pure `(seed, body, position)` (D-010); character never steps on a district/region line (D-243 edge-fuzz discipline).
|
||||
- **Raised by:** Jeroen (derive-don't-store, volumetric, drop-the-floor-cap directives) + Claude, atlas-derivation workshop, 2026-05-25.
|
||||
- **Cross-reference:** [D-010](#d-010) (determinism — now save-critical), [D-222](#d-222) (subtile/tile/chunk hierarchy), [D-110](#d-110) (signed z-levels), [D-225](#d-225) (layer-stream proxy + cache pattern), [D-203](#d-203) (LRU cache tier), [D-224](#d-224) (SeedChain — feeds `derive`), [D-228](#d-228) (composite tile schema — the derived value type), [Q-101](../questions/architecture.md#q-101) (refinement contract), [Q-103](../questions/architecture.md#q-103) (mutator op schema), [Q-104](../questions/architecture.md#q-104) (floor↔voxel-z mapping)
|
||||
- **Dissent:** None
|
||||
@@ -1830,6 +1831,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
- **Implementation note (T-1025/T-1027, 2026-06-08):** §6's frozen 17-zone `MorphologyZone` enum is realised in `server/src/simulation/generator.rs` (`repr(u8)`, discriminant-pinned). The §5 8-family gated classifier + §7 compatibility-matrix invariants live in `derive_morphology_zone` (`region_profile.rs`). **16 of the 17 zones are reachable at RegionProfile scale; `BraidedPlain` is the exception** — distinguishing it from `Delta` needs a lithology signal (§8 Gravel→braided) that `RegionProfile` does not carry, so `BraidedPlain` is **deferred to ChunkContext** sub-classification. The §7 compatibility invariant is enforced as a **classifier-gate-ordering** property (a build-time test that a single region cannot yield a forbidden pair), per §7's "build-time test" language; genuine cross-region sharp seams (cliff↔fjord, lithology faults) remain permitted. §2 climate-derived fields (`precipitation_class`, `glaciation_grade`, `vegetation_class`) all derive from the temperature(+moisture) primitive (T-1025).
|
||||
- **Amendment (T-1082, 2026-06-28):** §5's family set grows from **8 to 9** — a `WaterBody` generator family is added for the water zones **OpenOcean / Lake / TidalFlat**, which previously fell through to the dry-land `AlluvialPlain` fallback (the D-245 believability bug: oceans rendered as dry forested land — `water=Dry` + `Forest` on the sea). `WaterBody` lays `Water::Deep`/`Shallow` (a Shallow shoal band on the district-anchored coast line `coast_anchor_m`, Deep beyond; TidalFlat is all-Shallow), a seabed `TerrainMaterial` (`Rock` on steep districts / `Sand` on gentle / `Wetland` for tidal mud), `Vegetation::Barren`, the water surface at `elevation_m = 0` (the §8 mouths-at-sea-level convention), and seasonal `Ice` via `derive_cover` (frozen seas). This **refines §6**: open ocean / lake / tidal flat are still derived zone *labels*, but their *generator* is now `WaterBody`, not `AlluvialPlain` — so §6's "tidal flat … not [a] distinct generator famil[y]" is superseded for the dispatch of those three zones. `Wetland` stays a land zone on `AlluvialPlain` (a marsh — saturated ground, not open water). Lives in `server/src/atlas/voxel.rs` (`MorphologyFamily::WaterBody` + `generate_water_body`, dispatched by `zone_to_family`); verified by the T-1083 believability harness (water-renders-wet flips PASS for Arbour + Edict). The §5 8-family decision-tree classifier (`derive_morphology_zone`) is unchanged — this is a voxel-tier *generator* family, selected by zone, not a new RegionProfile classifier branch.
|
||||
- **Amendment (T-1080, 2026-06-28):** §2's **moisture** primitive is now a **body ceiling × per-district spatial gradient**, not a single body constant. The hydrosphere+atmosphere value (`derive_moisture_q`) is the *wettest* a district on the body can be; per-district **latitude** (equator wet → pole dry), **elevation** (orographic / rain-shadow), and **continentality** (`100 − ocean_fraction_q`, coast wet → interior dry) subtract from it. Integer (D-010); coefficients in `ClimateConstants` / `climate_constants.toml` `[moisture_gradient]` (provisional, Q-123 calibrates). This mirrors D-240's latitude-graded *temperature* model and fixes the T-1080 believability bug (`moisture_q = 80` for all 2 048 districts → uniform vegetation/terrain — "nothing to fuzz"). It **propagates**: `precipitation_class`, `vegetation_class`, and the `morphology_zone` Wetland gate all read `moisture_q`, so those diversify for free. Lives in `district_profile.rs::derive_moisture_q`; verified by the T-1083 believability harness (moisture-gradient flips PASS, `distinct` 1 → ~49 on Arbour). The body-scale ceiling keeps each world's character; the gradient varies it within.
|
||||
- **Amendment (T-1126, 2026-07-17):** §8's climate→vegetation law predates a water class — `derive_vegetation` was ocean-blind, so district-resolution maps painted Forest across open ocean (T-1123 finding; the voxel tier masked it in-world via the T-1082 `WaterBody` generator, but the district carrier itself disagreed with its own morphology). `VegetationClass` gains **`Marine`, appended at discriminant 6** (after `RiparianThicket = 5`; the load-bearing discriminant-pin test is extended). The verdict is **morphology-derived**: `build_district_profile` derives `morphology_zone` first and passes `open_water = (OpenOcean | Lake)` into the vegetation call — vegetation and morphology can never disagree by construction, and **no fourth ocean threshold exists**. Precedence: airless (`temperature_c == None`) still wins — an airless body has no climate/vegetation branch at all (§2), so its seas read `Absent`, not `Marine`. The `Ord` position of `Marine` is **non-semantic**: the §8 Forest→Scrub→Barren density-ladder reading does not extend to it (discriminants are append-only serialisation order). `near_perennial_water` (the riparian river-proximity signal) is unrelated and unchanged.
|
||||
- **Amendment (T-1081, 2026-06-28):** the voxel tier now carries **mid-scale relief** (D-243 §2's invented terrain). Before, the family generators set `elevation_m` from `elev_q` at a compressed scale plus only ±4 m per-voxel micro-scatter — the walkable surface read flat (the D-245 "0–3 m, no hills to navigate by" bug). A `voxel_relief` pass (`detail_scatter.rs`, the `terrain_detail` fBm at a **0.13–1 km sub-district octave band** — all finer than the 2 km district, so it never competes with `elev_q`'s district-scale role — rather than the district 4–40 km band; body-global `SeedDomain::VoxelRelief` seed, position-keyed; the `f64` perturbation truncated to integer metres before assignment — D-010) is added post-dispatch in `derive_voxel_column`. Two load-bearing choices: **(a) the relief envelope is `slope_q·3 + elev_q`, not slope alone** — the coarse heightmap (~40–78 km/px) yields `slope_q ≈ 0` even on high ground (observed max 13 on Arbour), so gating on slope would invent nothing; folding elevation in makes high terrain read rugged and coastal flats stay gentle. **(b) Only the flat families** (AlluvialPlain / LavaField / BraidedDelta / DuneStrand / MeanderReach) take it — CliffCoast / FjordWall / IncisedGorge already generate strong internal relief and a position-varying field would warp those features (e.g. drown a gorge's wall-to-floor drop); WaterBody stays at sea level. The relief **span** (`VOXEL_RELIEF_SPAN_M = 100`) and the believability voxel-relief threshold (≥ 8 m) are provisional (Q-123 calibrates, like the moisture coefficients). The span is held modest deliberately: relief sits on the compressed `elev_q/N` base (max ~50 m) and `elevation_m` clamps at 0 (sea level), so an oversized span clamps away on low ground (drowning the relief, biasing it positive) — a bigger span belongs with the deferred absolute-elevation model. Verified by the T-1083 harness — a new `voxel_relief` contrast metric (mean within-district elevation range across a district-spanning transect, since a single 64 m sample chunk is narrower than the relief band) flips PASS (≈22 m on Arbour + Edict). **Deferred:** the *absolute* elevation span is still the compressed `elev_q/N` base — a per-body hypsometric relief model (so a body's true max relief sets the ceiling AND gives the relief headroom to grow without clamping) is a later refinement; the mid-scale relief gives navigable hills now without it.
|
||||
- **Cross-reference:** [D-227](#d-227) (derive-don't-store voxel model), [D-228](#d-228) (composite tile axes / cohesion / seasonal state), [D-210](#d-210) (temperature proxy — formalised), [D-203](#d-203) (BodyWorldState cache), [D-206](#d-206) (background analysis pass), [D-208](#d-208) (drainage / D8), [D-010](#d-010) (determinism), [D-234](#d-234) (street/footprint geometry — consumes morphology), [D-142](content.md#d-142) (zone types), [D-217](#d-217) (tile condition), [Q-102](../questions/architecture.md#q-102) (cohesion = the warp), [Q-103](../questions/architecture.md#q-103) (mutator schema — open), [Q-105](../questions/architecture.md#q-105) (seasonal/clock state — temperature/ElevationDelta forward contract)
|
||||
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,9 @@ fn lattice(seed: u64, ix: i64, iy: i64) -> f64 {
|
||||
|
||||
/// 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 {
|
||||
/// `pub(crate)`: also the noise substrate of the T-1125 coastline invention
|
||||
/// ([`crate::atlas::coast_invention`]), which salts its own seed stream.
|
||||
pub(crate) 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());
|
||||
|
||||
@@ -22,6 +22,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::atlas::coast_invention;
|
||||
use crate::atlas::features::TerrainAnalysis;
|
||||
use crate::atlas::region_profile::{self, RegionProfile};
|
||||
use crate::atlas::scale::{self, BasinDirection, RegionPos};
|
||||
@@ -117,6 +118,13 @@ pub enum VegetationClass {
|
||||
/// Riparian Thicket — 1–3 tile band along perennial waterways in Forest zones.
|
||||
/// Takes precedence over Forest when adjacent to a perennial river.
|
||||
RiparianThicket = 5,
|
||||
/// Open water (T-1126): the district's morphology verdict is OpenOcean/Lake —
|
||||
/// no land vegetation. Set from the already-derived `MorphologyZone`, never
|
||||
/// from a water threshold of its own (vegetation and morphology can never
|
||||
/// disagree by construction). NOTE: the `Ord` position is NON-SEMANTIC —
|
||||
/// the D-239 §8 Forest>Scrub>Barren density-ladder reading does not extend
|
||||
/// to `Marine`; the discriminant is append-only serialisation order.
|
||||
Marine = 6,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -377,17 +385,31 @@ pub fn derive_glaciation_grade_from_climate(
|
||||
/// `near_perennial_water` signals whether this district is within the riparian
|
||||
/// band (i.e. the district or an adjacent district carries a perennial waterway).
|
||||
/// When true the riparian sub-variant is returned: `RiparianThicket` over
|
||||
/// Forest, `RiparianScrub` over Scrub/Barren.
|
||||
/// Forest, `RiparianScrub` over Scrub/Barren. It is a RIVER-proximity signal —
|
||||
/// unrelated to `open_water`, which is the ocean/lake verdict.
|
||||
///
|
||||
/// `open_water` (T-1126) is the **morphology-derived** water verdict — `true`
|
||||
/// when the district's already-derived `MorphologyZone` is OpenOcean/Lake.
|
||||
/// Callers pass the verdict in; this function must NEVER re-derive it from an
|
||||
/// ocean-fraction threshold of its own (no fourth threshold — vegetation and
|
||||
/// morphology can never disagree by construction). Airless takes precedence:
|
||||
/// an airless body has no climate/vegetation branch at all (D-239 §2), so its
|
||||
/// seas read `Absent`, not `Marine`.
|
||||
pub fn derive_vegetation(
|
||||
temperature_c: Option<f32>,
|
||||
moisture_q: i32,
|
||||
elev_q: i32,
|
||||
near_perennial_water: bool,
|
||||
open_water: bool,
|
||||
) -> VegetationClass {
|
||||
// Airless: entire climate/vegetation branch absent.
|
||||
let Some(temp_c) = temperature_c else {
|
||||
return VegetationClass::Absent;
|
||||
};
|
||||
// Open water (T-1126): the morphology verdict, passed in — see doc above.
|
||||
if open_water {
|
||||
return VegetationClass::Marine;
|
||||
}
|
||||
let temp_i = temp_c as i32;
|
||||
|
||||
// Extreme cold → Barren regardless of water proximity: below ~−50 °C mean
|
||||
@@ -1032,6 +1054,140 @@ pub fn derive_moisture_q(
|
||||
(ceiling - lat_penalty - elev_penalty - cont_penalty).clamp(0, 100)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-1125 — invented primitives (shared by both derivation paths)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The invented `(slope_q, elev_q, ocean_fraction_q)` triple (T-1125, D-227).
|
||||
struct InventedPrimitives {
|
||||
slope_q: i32,
|
||||
elev_q: i32,
|
||||
ocean_fraction_q: i32,
|
||||
}
|
||||
|
||||
/// Compose the invented district primitives at one position — the shared front
|
||||
/// half of BOTH derivation paths ([`derive_district`] on-demand and
|
||||
/// [`derive_district_profile`] batch), so the D-227 invention can never diverge
|
||||
/// between them again (the pre-T-1125 failure: the batch path was scatter-free).
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. **Driver tier (one-step-stale, T-1125 circularity ruling):** the raw
|
||||
/// *unwarped* bilinear envelope feeds a provisional moisture/temperature →
|
||||
/// [`derive_glaciation_grade_from_climate`] — the coast character reads
|
||||
/// these stale drivers; it never reads the warped values it produces. The
|
||||
/// raw inputs are the coarse ~40–160 km/pixel envelope anyway, so one step
|
||||
/// of staleness is far below the signal's own resolution.
|
||||
/// 2. **Character:** tier-1 body envelope ([`coast_invention::body_coast_envelope`];
|
||||
/// `derive_tectonic_class` hoisted here — it needs only `BodyParams`) +
|
||||
/// tier-2 position character ([`coast_invention::coast_character_at`]).
|
||||
/// 3. **Invented coastline:** every envelope field (elevation, slope, ocean
|
||||
/// mask) is sampled at the SAME warped position, so terrain moves coherently
|
||||
/// — a warped-in bay carries its sea-level elevation with it.
|
||||
/// 4. **Slope-independent scatter:** the character's `scatter_floor` breaks the
|
||||
/// old amplitude-slaved-to-coarse-slope collapse (invention ≈ 0 exactly on
|
||||
/// low-relief coasts); `ridge` carries fjord/tectonic sharpness.
|
||||
///
|
||||
/// `body_params` must already carry the district's `latitude_deg`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn invent_primitives(
|
||||
seed: SeedChain,
|
||||
body_params: &BodyParams,
|
||||
climate: &ClimateConstants,
|
||||
ta: &TerrainAnalysis,
|
||||
px: f64,
|
||||
py: f64,
|
||||
world_x_m: f64,
|
||||
world_y_m: f64,
|
||||
region_baseline_c: Option<f32>,
|
||||
) -> InventedPrimitives {
|
||||
// ── 1. Driver tier: UNWARPED raw-bilinear climate (one-step-stale). ─────
|
||||
let raw_elev_q =
|
||||
((bilinear(&ta.elev_pct, ta.w, ta.h, px, py) as f64 * 100.0).round() as i32).clamp(0, 100);
|
||||
let raw_ocean_q = ((bilinear_bool(&ta.ocean_mask, ta.w, ta.h, px, py) as f64 * 100.0).round()
|
||||
as i32)
|
||||
.clamp(0, 100);
|
||||
let driver_params = BodyParams {
|
||||
elevation_km: (raw_elev_q as f64 / 100.0) * MAX_REGION_ELEVATION_KM,
|
||||
..body_params.clone()
|
||||
};
|
||||
let driver_temp = match region_baseline_c {
|
||||
Some(b) => derive_district_temperature_c(Some(b), &driver_params, climate, 0.0),
|
||||
None => derive_temperature_c(&driver_params, climate, seed.seed()),
|
||||
};
|
||||
let driver_moisture = derive_moisture_q(&driver_params, raw_elev_q, raw_ocean_q, climate);
|
||||
let driver_glaciation = derive_glaciation_grade_from_climate(driver_temp, driver_moisture);
|
||||
|
||||
// ── 2. Character: body personality envelope + position modulation. ──────
|
||||
let tectonic = derive_tectonic_class(body_params);
|
||||
let envelope = coast_invention::body_coast_envelope(body_params, tectonic);
|
||||
let ch = coast_invention::coast_character_at(
|
||||
&envelope,
|
||||
seed.seed(),
|
||||
world_x_m,
|
||||
world_y_m,
|
||||
body_params.latitude_deg,
|
||||
driver_glaciation,
|
||||
driver_moisture,
|
||||
);
|
||||
|
||||
// ── 3. Invented coastline: warp the whole envelope sampling. ────────────
|
||||
let (wdx, wdy) = coast_invention::coast_warp_px(seed.seed(), world_x_m, world_y_m, &ch);
|
||||
let (spx, spy) = (px + wdx, py + wdy);
|
||||
let elev_pct = bilinear(&ta.elev_pct, ta.w, ta.h, spx, spy) as f64;
|
||||
let slope_deg = bilinear(&ta.slope_deg, ta.w, ta.h, spx, spy) as f64;
|
||||
let ocean_frac = bilinear_bool(&ta.ocean_mask, ta.w, ta.h, spx, spy) as f64;
|
||||
|
||||
// ── 4. Slope-independent scatter (floor + ridge character). ─────────────
|
||||
let local_slope = (slope_deg / 45.0).clamp(0.0, 1.0);
|
||||
let env_amp = (local_slope + ch.scatter_floor).clamp(0.0, 1.0);
|
||||
let ruggedness = (0.6 * local_slope + 0.5 * ch.ridge).clamp(0.0, 1.0);
|
||||
let scatter = crate::atlas::detail_scatter::terrain_detail(
|
||||
seed.seed(),
|
||||
world_x_m,
|
||||
world_y_m,
|
||||
env_amp,
|
||||
ruggedness,
|
||||
);
|
||||
|
||||
// Shoreline carving (T-1125): glacial / tectonically-young SHORES are cut
|
||||
// steep — fjord walls and cliff coasts. The gentle scatter floor alone can
|
||||
// never voice the D-239 steep coastal families (Fjord gates at slope_q ≥ 40,
|
||||
// CliffCoast at ≥ 55), so where the invented position is coastal
|
||||
// (`shoreline` peaks at the ocean-fraction midpoint) and the character is
|
||||
// ridged (fjord/tectonic), the normalized scatter contributes real slope —
|
||||
// patchy by construction (it reuses the scatter noise), so a glaciated
|
||||
// coast grows fjord SEGMENTS, not a uniform fjord stripe.
|
||||
let shoreline = (ocean_frac * (1.0 - ocean_frac) * 4.0).clamp(0.0, 1.0);
|
||||
let carve = 0.55 * ch.ridge * shoreline * (scatter.abs() / env_amp.max(0.05)).clamp(0.0, 1.0);
|
||||
|
||||
InventedPrimitives {
|
||||
elev_q: (((elev_pct + scatter) * 100.0).round() as i32).clamp(0, 100),
|
||||
slope_q: (((local_slope + ruggedness * scatter.abs() + carve) * 100.0).round() as i32)
|
||||
.clamp(0, 100),
|
||||
ocean_fraction_q: ((ocean_frac * 100.0).round() as i32).clamp(0, 100),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fractional working-grid position → absolute world metres — the inverse of
|
||||
/// [`derive_district`]'s district→pixel mapping (D-204 elastic seam), used by
|
||||
/// the batch path so both derivation paths key the invention noise fields on
|
||||
/// the same world-metre convention. Radius-less bodies fall back to the
|
||||
/// 1-working-pixel = 1-district convention `derive_district` uses.
|
||||
fn pixel_to_world_m(px: f64, py: f64, w: usize, h: usize, radius_km: Option<f64>) -> (f64, f64) {
|
||||
match radius_km {
|
||||
Some(r) if r > 0.0 => {
|
||||
let wx = px / w.max(1) as f64 * (std::f64::consts::TAU * r * 1000.0);
|
||||
let lat_frac = if h > 1 {
|
||||
py / (h - 1) as f64 - 0.5
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
(wx, lat_frac * (std::f64::consts::PI * r * 1000.0))
|
||||
}
|
||||
_ => (px * scale::DISTRICT_M as f64, py * scale::DISTRICT_M as f64),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public derivation function
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1075,43 +1231,27 @@ pub fn derive_district_profile(
|
||||
let h = ta.h;
|
||||
let gcpr = grid_cells_per_district.max(1);
|
||||
|
||||
// Compute aggregate terrain statistics over the cells in this district.
|
||||
// All arithmetic is integer or quantized-integer (D-010).
|
||||
let mut slope_sum: i64 = 0;
|
||||
let mut elev_sum: i64 = 0;
|
||||
let mut ocean_count: i64 = 0;
|
||||
let mut cell_count: i64 = 0;
|
||||
|
||||
// T-1125: the batch path runs the SAME invention as the on-demand path — an
|
||||
// invented centre-point sample of the continuous field (warped coastline +
|
||||
// slope-independent scatter via `invent_primitives`) instead of the former
|
||||
// scatter-free cell-aggregate mean. One invention path, shared with
|
||||
// [`derive_district`], so the two can never silently diverge again; the
|
||||
// pseudo-grid samples the same truth the 2 km carrier does. (The former mean
|
||||
// smoothed away exactly the variance the believability contrast metrics
|
||||
// measure — and carried no invention at all.)
|
||||
let row_start = (ry as usize).saturating_mul(gcpr).min(h);
|
||||
let row_end = row_start.saturating_add(gcpr).min(h);
|
||||
let col_start = (rx as usize).saturating_mul(gcpr).min(w);
|
||||
let col_end = col_start.saturating_add(gcpr).min(w);
|
||||
|
||||
for r in row_start..row_end {
|
||||
for c in col_start..col_end {
|
||||
let i = r * w + c;
|
||||
// Quantize slope to 0–100: slope_deg is ~[0, 45°]; divide by 45 × 100.
|
||||
// This is a float-to-int boundary inside the aggregation; downstream
|
||||
// decisions use `slope_q` (integer).
|
||||
slope_sum += ((ta.slope_deg[i] / 45.0).clamp(0.0, 1.0) * 100.0) as i64;
|
||||
elev_sum += (ta.elev_pct[i] * 100.0) as i64;
|
||||
ocean_count += ta.ocean_mask[i] as i64;
|
||||
cell_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let (slope_q, elev_q, ocean_fraction_q) = if cell_count > 0 {
|
||||
let sq = (slope_sum / cell_count) as i32;
|
||||
let eq = (elev_sum / cell_count) as i32;
|
||||
let oq = (ocean_count * 100 / cell_count) as i32;
|
||||
(sq, eq, oq)
|
||||
} else {
|
||||
(0, 0, 0)
|
||||
};
|
||||
let px = (col_start as f64 + col_end.saturating_sub(1).max(col_start) as f64) / 2.0;
|
||||
let py = (row_start as f64 + row_end.saturating_sub(1).max(row_start) as f64) / 2.0;
|
||||
let (world_x_m, world_y_m) = pixel_to_world_m(px, py, w, h, body_params.body_radius_km);
|
||||
|
||||
// D-243 §3/§4: compute the edge-fuzz-blended region baseline for this district,
|
||||
// then pass it through build_district_profile so the two-phase derivation path runs.
|
||||
// The warp uses `seed.seed()` (the body-scoped seed) for domain separation.
|
||||
// Hoisted above the primitives (T-1125): the invention's driver tier needs
|
||||
// the baseline for its one-step-stale climate estimate.
|
||||
let region_baseline_c = region_profile::region_baseline_at_district(
|
||||
seed.seed(),
|
||||
body_id,
|
||||
@@ -1122,13 +1262,25 @@ pub fn derive_district_profile(
|
||||
Some(region_cache),
|
||||
);
|
||||
|
||||
let prims = invent_primitives(
|
||||
seed,
|
||||
body_params,
|
||||
climate,
|
||||
ta,
|
||||
px,
|
||||
py,
|
||||
world_x_m,
|
||||
world_y_m,
|
||||
region_baseline_c,
|
||||
);
|
||||
|
||||
build_district_profile(
|
||||
seed,
|
||||
body_params,
|
||||
climate,
|
||||
slope_q,
|
||||
elev_q,
|
||||
ocean_fraction_q,
|
||||
prims.slope_q,
|
||||
prims.elev_q,
|
||||
prims.ocean_fraction_q,
|
||||
region_baseline_c,
|
||||
basin_direction,
|
||||
)
|
||||
@@ -1200,11 +1352,8 @@ fn build_district_profile(
|
||||
let glaciation_grade = derive_glaciation_grade_from_climate(temperature_c, moisture_q);
|
||||
let river_threshold = derive_river_threshold(tectonic_class, precipitation_class);
|
||||
|
||||
// Vegetation class (T-1025, D-239 §8). No riparian signal at district scale yet
|
||||
// (requires perennial waterway map from L2+); default to false for now.
|
||||
// L2 ChunkContext will override per-tile once drainage data is threaded through.
|
||||
let vegetation_class = derive_vegetation(temperature_c, moisture_q, elev_q, false);
|
||||
|
||||
// Morphology precedes vegetation (T-1126): the vegetation call consumes the
|
||||
// morphology water verdict, so the two can never disagree by construction.
|
||||
let morphology_zone = derive_morphology_zone(
|
||||
tectonic_class,
|
||||
glaciation_grade,
|
||||
@@ -1214,6 +1363,16 @@ fn build_district_profile(
|
||||
moisture_q,
|
||||
);
|
||||
|
||||
// Vegetation class (T-1025, D-239 §8). No riparian signal at district scale yet
|
||||
// (requires perennial waterway map from L2+); default to false for now.
|
||||
// L2 ChunkContext will override per-tile once drainage data is threaded through.
|
||||
// open_water = the morphology verdict (T-1126) — never a threshold of its own.
|
||||
let open_water = matches!(
|
||||
morphology_zone,
|
||||
MorphologyZone::OpenOcean | MorphologyZone::Lake
|
||||
);
|
||||
let vegetation_class = derive_vegetation(temperature_c, moisture_q, elev_q, false, open_water);
|
||||
|
||||
DistrictProfile {
|
||||
morphology_zone,
|
||||
tectonic_class,
|
||||
@@ -1291,32 +1450,6 @@ pub fn derive_district(
|
||||
}
|
||||
};
|
||||
|
||||
// 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 {
|
||||
latitude_deg: lat_deg,
|
||||
..body_params.clone()
|
||||
@@ -1326,6 +1459,8 @@ pub fn derive_district(
|
||||
// this district. No pre-built cache here — the on-demand path derives the four
|
||||
// surrounding region baselines directly. Pure, deterministic, cheap.
|
||||
// `seed.seed()` (the body-scoped seed value) ensures body-unique warp separation.
|
||||
// Hoisted above the primitives (T-1125): the invention's driver tier needs
|
||||
// the baseline for its one-step-stale climate estimate.
|
||||
let region_baseline_c = region_profile::region_baseline_at_district(
|
||||
seed.seed(),
|
||||
body_id,
|
||||
@@ -1336,6 +1471,21 @@ pub fn derive_district(
|
||||
None, // no pre-built cache; derive on-the-fly
|
||||
);
|
||||
|
||||
// T-1125: invented primitives — warped coastline (invented bays/capes) +
|
||||
// slope-independent character-driven scatter. Shared with the batch path
|
||||
// (`derive_district_profile`) via `invent_primitives`.
|
||||
let prims = invent_primitives(
|
||||
seed,
|
||||
¶ms,
|
||||
climate,
|
||||
ta,
|
||||
px,
|
||||
py,
|
||||
world_x_m,
|
||||
world_y_m,
|
||||
region_baseline_c,
|
||||
);
|
||||
|
||||
// derive_district is the on-demand path (arbitrary DistrictPos, no L1 working
|
||||
// grid). basin_direction is an ACCEPTED LIMITATION here: it defaults to North
|
||||
// (a fallback, not a computed value) because the D8 thalweg is only available
|
||||
@@ -1347,9 +1497,9 @@ pub fn derive_district(
|
||||
seed,
|
||||
¶ms,
|
||||
climate,
|
||||
slope_q,
|
||||
elev_q,
|
||||
ocean_fraction_q,
|
||||
prims.slope_q,
|
||||
prims.elev_q,
|
||||
prims.ocean_fraction_q,
|
||||
region_baseline_c,
|
||||
BasinDirection::default(),
|
||||
)
|
||||
@@ -1647,9 +1797,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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).
|
||||
fn derive_district_flat_envelope_bounded_invention() {
|
||||
// Pre-T-1125 this pinned "flat heightmap → ZERO invented relief" — the
|
||||
// envelope-as-amplitude reading that collapsed the D-227 invention on
|
||||
// exactly the low-relief coasts where coastlines live. T-1125 (Jeroen's
|
||||
// crinkle-varies ruling) replaces the zero with a bounded FLOOR: a flat
|
||||
// world gains gentle invented relief that varies from place to place,
|
||||
// never mountains, and the invention cannot conjure water that is not
|
||||
// in the ocean mask.
|
||||
let (w, h) = (64u32, 32u32);
|
||||
let flat = BodyHeightmap {
|
||||
body_id: "flat".into(),
|
||||
@@ -1661,13 +1816,27 @@ mod tests {
|
||||
let ta = test_ta(&flat);
|
||||
let climate = ClimateConstants::default();
|
||||
let p = earth_params();
|
||||
let d = derive_district(test_seed(), "flat", &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"
|
||||
let a = derive_district(test_seed(), "flat", &p, &ta, (500, 100), &climate);
|
||||
let b = derive_district(test_seed(), "flat", &p, &ta, (900, 40), &climate);
|
||||
// No ocean can be invented on a fully-landlocked flat world: the coast
|
||||
// warp displaces sampling, it never fabricates mask content.
|
||||
assert_eq!(a.ocean_fraction_q, 0, "above sea level → no ocean");
|
||||
assert_eq!(b.ocean_fraction_q, 0, "above sea level → no ocean");
|
||||
// Bounded invention: gentle relief only — an authored plain must never
|
||||
// sprout mountain-grade slopes (the envelope rule survives as a ceiling).
|
||||
assert!(a.slope_q <= 25, "invented slope too steep: {}", a.slope_q);
|
||||
assert!(b.slope_q <= 25, "invented slope too steep: {}", b.slope_q);
|
||||
// And the invention must actually vary between distant districts — the
|
||||
// old zero rule left every flat district identical (the T-1125 finding).
|
||||
assert!(
|
||||
a.elev_q != b.elev_q || a.slope_q != b.slope_q,
|
||||
"flat-world districts must differ under the invention floor \
|
||||
(a: elev_q={} slope_q={}, b: elev_q={} slope_q={})",
|
||||
a.elev_q,
|
||||
a.slope_q,
|
||||
b.elev_q,
|
||||
b.slope_q
|
||||
);
|
||||
assert_eq!(d.ocean_fraction_q, 0, "above sea level → no ocean");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2325,7 +2494,7 @@ mod tests {
|
||||
fn vegetation_airless_is_absent() {
|
||||
// D-239 §2: airless body → vegetation branch absent.
|
||||
assert_eq!(
|
||||
derive_vegetation(None, 50, 20, false),
|
||||
derive_vegetation(None, 50, 20, false, false),
|
||||
VegetationClass::Absent
|
||||
);
|
||||
}
|
||||
@@ -2334,7 +2503,7 @@ mod tests {
|
||||
fn vegetation_warm_moist_low_elevation_is_forest() {
|
||||
// Warm + moist + low elevation → Forest.
|
||||
assert_eq!(
|
||||
derive_vegetation(Some(20.0), 60, 10, false),
|
||||
derive_vegetation(Some(20.0), 60, 10, false, false),
|
||||
VegetationClass::Forest
|
||||
);
|
||||
}
|
||||
@@ -2343,7 +2512,7 @@ mod tests {
|
||||
fn vegetation_high_elevation_is_barren() {
|
||||
// Warm + moist but very high elevation → Barren (above treeline).
|
||||
assert_eq!(
|
||||
derive_vegetation(Some(20.0), 60, 90, false),
|
||||
derive_vegetation(Some(20.0), 60, 90, false, false),
|
||||
VegetationClass::Barren
|
||||
);
|
||||
}
|
||||
@@ -2356,7 +2525,7 @@ mod tests {
|
||||
let moisture = 60_i32;
|
||||
let mut prev: Option<VegetationClass> = None;
|
||||
for elev_q in (0..=100).step_by(5) {
|
||||
let v = derive_vegetation(temp, moisture, elev_q, false);
|
||||
let v = derive_vegetation(temp, moisture, elev_q, false, false);
|
||||
if let Some(p) = prev {
|
||||
// Absent not reachable here (has atmosphere); skip riparian variants.
|
||||
let is_base = matches!(
|
||||
@@ -2383,14 +2552,14 @@ mod tests {
|
||||
#[test]
|
||||
fn vegetation_riparian_upgrades_scrub_to_riparian_scrub() {
|
||||
// A scrub-zone district near perennial water → RiparianScrub.
|
||||
let v = derive_vegetation(Some(20.0), 60, 85, true); // high elev = scrub zone
|
||||
let v = derive_vegetation(Some(20.0), 60, 85, true, false); // high elev = scrub zone
|
||||
assert_eq!(v, VegetationClass::RiparianScrub);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vegetation_riparian_upgrades_forest_to_riparian_thicket() {
|
||||
// A forest-zone district near perennial water → RiparianThicket.
|
||||
let v = derive_vegetation(Some(20.0), 60, 10, true); // low elev = forest zone
|
||||
let v = derive_vegetation(Some(20.0), 60, 10, true, false); // low elev = forest zone
|
||||
assert_eq!(v, VegetationClass::RiparianThicket);
|
||||
}
|
||||
|
||||
@@ -2403,13 +2572,37 @@ mod tests {
|
||||
assert_eq!(VegetationClass::Forest as u8, 3);
|
||||
assert_eq!(VegetationClass::RiparianScrub as u8, 4);
|
||||
assert_eq!(VegetationClass::RiparianThicket as u8, 5);
|
||||
// T-1126: Marine appended (open-water verdict). Ord position is
|
||||
// NON-SEMANTIC — the density ladder does not extend to Marine.
|
||||
assert_eq!(VegetationClass::Marine as u8, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vegetation_open_water_is_marine_and_airless_wins() {
|
||||
// T-1126: the morphology water verdict → Marine…
|
||||
assert_eq!(
|
||||
derive_vegetation(Some(15.0), 80, 5, false, true),
|
||||
VegetationClass::Marine
|
||||
);
|
||||
// …but airless takes precedence: no climate/vegetation branch at all
|
||||
// (D-239 §2) — an airless sea is Absent, not Marine.
|
||||
assert_eq!(
|
||||
derive_vegetation(None, 80, 5, false, true),
|
||||
VegetationClass::Absent
|
||||
);
|
||||
// The riparian flag is river-proximity, unrelated to open water: a
|
||||
// riparian river district on land is NOT Marine.
|
||||
assert_eq!(
|
||||
derive_vegetation(Some(15.0), 80, 5, true, false),
|
||||
VegetationClass::RiparianThicket
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vegetation_hyper_arid_is_barren_not_absent() {
|
||||
// With atmosphere but zero moisture → Barren (not Absent).
|
||||
assert_eq!(
|
||||
derive_vegetation(Some(20.0), 0, 10, false),
|
||||
derive_vegetation(Some(20.0), 0, 10, false, false),
|
||||
VegetationClass::Barren
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod body_world_state;
|
||||
pub mod cascade;
|
||||
pub mod chunk_context;
|
||||
pub mod city_context_reader;
|
||||
pub mod coast_invention;
|
||||
pub mod detail_scatter;
|
||||
pub mod district_mix;
|
||||
pub mod district_profile;
|
||||
|
||||
@@ -160,6 +160,11 @@ impl Vegetation {
|
||||
VegetationClass::Forest => Vegetation::Forest,
|
||||
VegetationClass::RiparianScrub => Vegetation::Thicket,
|
||||
VegetationClass::RiparianThicket => Vegetation::Thicket,
|
||||
// T-1126: open-water districts route to the WaterBody generator
|
||||
// upstream (D-239/T-1082) and never reach the land-vegetation
|
||||
// baseline; the arm exists for exhaustiveness and maps to bare
|
||||
// ground exactly like Absent if one ever slips through.
|
||||
VegetationClass::Marine => Vegetation::Barren,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,8 @@ use settled_reach_server::atlas::believability::{
|
||||
use settled_reach_server::atlas::body_world_state::BodyWorldState;
|
||||
use settled_reach_server::atlas::chunk_context::derive_chunk_context;
|
||||
use settled_reach_server::atlas::district_profile::{
|
||||
derive_district, BodyParams, ClimateConstants, DistrictProfile, VegetationClass,
|
||||
derive_district, BodyParams, ClimateConstants, DistrictProfile, GlaciationGrade,
|
||||
VegetationClass,
|
||||
};
|
||||
use settled_reach_server::atlas::features::TerrainAnalysis;
|
||||
use settled_reach_server::atlas::scale::{
|
||||
@@ -335,8 +336,17 @@ const MORPHOLOGY_RGB: [[u8; 3]; 17] = [
|
||||
[64, 115, 102], // 16 Wetland
|
||||
];
|
||||
|
||||
/// The five attribute panels, in output order.
|
||||
const PANEL_NAMES: [&str; 5] = ["morphology", "elev", "temp", "moisture", "veg"];
|
||||
/// The attribute panels, in output order. `glaciation` is T-1127's dedicated
|
||||
/// panel; the morphology panel additionally carries a per-pixel ice tint keyed
|
||||
/// on the same field (see [`apply_ice_tint`]).
|
||||
const PANEL_NAMES: [&str; 6] = [
|
||||
"morphology",
|
||||
"elev",
|
||||
"temp",
|
||||
"moisture",
|
||||
"veg",
|
||||
"glaciation",
|
||||
];
|
||||
|
||||
/// Render the district window around the principal settlement and write one PNG
|
||||
/// per panel to `out_dir`, plus a one-line manifest per image and a determinism
|
||||
@@ -447,7 +457,7 @@ fn render_window_panels(
|
||||
) -> Vec<(&'static str, Vec<u8>)> {
|
||||
let n_i = n as i32;
|
||||
let half = n_i / 2;
|
||||
let mut bufs: [Vec<u8>; 5] = std::array::from_fn(|_| vec![0u8; (n * n) as usize * 3]);
|
||||
let mut bufs: [Vec<u8>; 6] = std::array::from_fn(|_| vec![0u8; (n * n) as usize * 3]);
|
||||
for row in 0..n_i {
|
||||
for col in 0..n_i {
|
||||
// Row 0 = northmost: smaller dy = further north (derive_district maps
|
||||
@@ -455,11 +465,19 @@ fn render_window_panels(
|
||||
let dp = (centre.0 - half + col, centre.1 - half + row);
|
||||
let prof = derive_district(seed, body_id, params, ta, dp, climate);
|
||||
let i = ((row * n_i + col) * 3) as usize;
|
||||
set_rgb(&mut bufs[0], i, morphology_rgb(prof.morphology_zone));
|
||||
// T-1127: the morphology color carries a per-pixel ice tint keyed on
|
||||
// glaciation_grade (a render modifier — the frozen D-239 §6 zone
|
||||
// vocabulary is untouched; tint = classifier-tuning territory).
|
||||
set_rgb(
|
||||
&mut bufs[0],
|
||||
i,
|
||||
apply_ice_tint(morphology_rgb(prof.morphology_zone), prof.glaciation_grade),
|
||||
);
|
||||
set_rgb(&mut bufs[1], i, gray_rgb(prof.elev_q));
|
||||
set_rgb(&mut bufs[2], i, temperature_rgb(prof.temperature_c));
|
||||
set_rgb(&mut bufs[3], i, moisture_rgb(prof.moisture_q));
|
||||
set_rgb(&mut bufs[4], i, vegetation_rgb(prof.vegetation_class));
|
||||
set_rgb(&mut bufs[5], i, glaciation_rgb(prof.glaciation_grade));
|
||||
}
|
||||
}
|
||||
for buf in &mut bufs {
|
||||
@@ -566,6 +584,43 @@ fn vegetation_rgb(v: VegetationClass) -> [u8; 3] {
|
||||
VegetationClass::Forest => [30, 110, 40], // closed canopy
|
||||
VegetationClass::RiparianScrub => [70, 170, 120], // waterway band (open)
|
||||
VegetationClass::RiparianThicket => [10, 130, 90], // waterway band (dense)
|
||||
// T-1126: open water — an ocean blue deliberately DISTINCT from the
|
||||
// morphology water tones (OpenOcean 26,51,115 / Lake 51,102,166) so a
|
||||
// veg-panel Marine cell is never mistaken for a morphology overlay.
|
||||
VegetationClass::Marine => [0, 96, 172],
|
||||
}
|
||||
}
|
||||
|
||||
/// T-1127: blend the morphology color toward glacial ice-white by
|
||||
/// `glaciation_grade` — a RENDER modifier only (the D-239 §6 frozen 17-zone
|
||||
/// vocabulary is untouched; an actual ice zone would need its own D-amendment).
|
||||
///
|
||||
/// `Light` deliberately does NOT tint: grade 1 is glacial-erosion *signatures*
|
||||
/// (U-valleys, moraines — landform history on coasts as warm as +5 °C mean),
|
||||
/// not ice cover; visible ice starts where D-239 §5 gates glacial forms,
|
||||
/// grade ≥ Moderate. The dedicated glaciation panel still shows Light.
|
||||
fn apply_ice_tint(base: [u8; 3], grade: GlaciationGrade) -> [u8; 3] {
|
||||
let t = match grade {
|
||||
GlaciationGrade::None | GlaciationGrade::Light => return base,
|
||||
GlaciationGrade::Moderate => 0.30,
|
||||
GlaciationGrade::Heavy => 0.50,
|
||||
GlaciationGrade::IceCap => 0.70,
|
||||
};
|
||||
lerp_rgb(
|
||||
[base[0] as f32, base[1] as f32, base[2] as f32],
|
||||
[228.0, 240.0, 250.0], // glacial ice white-blue
|
||||
t,
|
||||
)
|
||||
}
|
||||
|
||||
/// T-1127: dedicated glaciation panel — categorical dark→ice ramp.
|
||||
fn glaciation_rgb(grade: GlaciationGrade) -> [u8; 3] {
|
||||
match grade {
|
||||
GlaciationGrade::None => [30, 30, 30],
|
||||
GlaciationGrade::Light => [96, 120, 150],
|
||||
GlaciationGrade::Moderate => [150, 180, 210],
|
||||
GlaciationGrade::Heavy => [200, 222, 240],
|
||||
GlaciationGrade::IceCap => [240, 248, 255],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -841,7 +841,7 @@ fn law_climate_vegetation_no_skip_temperature_sweep() {
|
||||
let mut last: Option<VegetationClass> = None;
|
||||
for t_i in (-60i32..=30).rev().step_by(5) {
|
||||
let temp = Some(t_i as f32);
|
||||
let vc = derive_vegetation(temp, moisture_q, elev_q, false);
|
||||
let vc = derive_vegetation(temp, moisture_q, elev_q, false, false);
|
||||
if let Some(prev) = last {
|
||||
if prev == VegetationClass::Forest && vc == VegetationClass::Barren {
|
||||
panic!(
|
||||
@@ -861,7 +861,7 @@ fn law_climate_vegetation_no_skip_elevation_sweep() {
|
||||
let moisture_q = 55;
|
||||
let mut last: Option<VegetationClass> = None;
|
||||
for elev_q in (0i32..=100).step_by(5) {
|
||||
let vc = derive_vegetation(temp, moisture_q, elev_q, false);
|
||||
let vc = derive_vegetation(temp, moisture_q, elev_q, false, false);
|
||||
if let Some(prev) = last {
|
||||
if prev == VegetationClass::Forest && vc == VegetationClass::Barren {
|
||||
panic!(
|
||||
@@ -898,7 +898,7 @@ fn law_climate_vegetation_full_grid_no_skip() {
|
||||
for elev_q in (0i32..=100).step_by(10) {
|
||||
let mut last: Option<VegetationClass> = None;
|
||||
for &temp in &temps_desc {
|
||||
let vc = derive_vegetation(temp, moisture_q, elev_q, false);
|
||||
let vc = derive_vegetation(temp, moisture_q, elev_q, false, false);
|
||||
if let Some(prev) = last {
|
||||
if prev == VegetationClass::Forest && vc == VegetationClass::Barren {
|
||||
panic!(
|
||||
@@ -919,7 +919,7 @@ fn law_climate_vegetation_riparian_near_perennial_water() {
|
||||
// near_perennial_water=true must produce a Riparian variant in viable zones.
|
||||
|
||||
// Forest zone → RiparianThicket.
|
||||
let vc_forest = derive_vegetation(Some(18.0), 60, 20, true);
|
||||
let vc_forest = derive_vegetation(Some(18.0), 60, 20, true, false);
|
||||
assert!(
|
||||
matches!(
|
||||
vc_forest,
|
||||
@@ -930,7 +930,7 @@ fn law_climate_vegetation_riparian_near_perennial_water() {
|
||||
);
|
||||
|
||||
// Scrub zone → RiparianScrub.
|
||||
let vc_scrub = derive_vegetation(Some(5.0), 30, 50, true);
|
||||
let vc_scrub = derive_vegetation(Some(5.0), 30, 50, true, false);
|
||||
assert!(
|
||||
matches!(
|
||||
vc_scrub,
|
||||
@@ -941,7 +941,7 @@ fn law_climate_vegetation_riparian_near_perennial_water() {
|
||||
);
|
||||
|
||||
// Hyper-arid Barren zone + perennial water → RiparianScrub oasis (D-239 §8).
|
||||
let vc_arid = derive_vegetation(Some(20.0), 3, 10, true);
|
||||
let vc_arid = derive_vegetation(Some(20.0), 3, 10, true, false);
|
||||
assert_eq!(
|
||||
vc_arid,
|
||||
VegetationClass::RiparianScrub,
|
||||
@@ -957,7 +957,7 @@ fn law_climate_vegetation_airless_always_absent() {
|
||||
for moisture_q in [0, 30, 70, 100] {
|
||||
for elev_q in [0, 50, 100] {
|
||||
for near_water in [false, true] {
|
||||
let vc = derive_vegetation(None, moisture_q, elev_q, near_water);
|
||||
let vc = derive_vegetation(None, moisture_q, elev_q, near_water, false);
|
||||
assert_eq!(
|
||||
vc,
|
||||
VegetationClass::Absent,
|
||||
|
||||
@@ -6,38 +6,38 @@
|
||||
"voxel_sampled_districts": 64,
|
||||
"contrast": {
|
||||
"moisture_q": {
|
||||
"min": 32,
|
||||
"min": 31,
|
||||
"max": 80,
|
||||
"distinct": 49
|
||||
"distinct": 48
|
||||
},
|
||||
"elev_q": {
|
||||
"min": 0,
|
||||
"max": 98,
|
||||
"distinct": 99
|
||||
"max": 100,
|
||||
"distinct": 101
|
||||
},
|
||||
"slope_q": {
|
||||
"min": 0,
|
||||
"max": 13,
|
||||
"distinct": 14
|
||||
"max": 30,
|
||||
"distinct": 30
|
||||
},
|
||||
"ocean_fraction_q": {
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"distinct": 65
|
||||
"distinct": 44
|
||||
},
|
||||
"morphology_zones": 9,
|
||||
"vegetation_classes": 3,
|
||||
"vegetation_classes": 4,
|
||||
"terrain_materials": 4,
|
||||
"voxel_relief_m": 20,
|
||||
"voxel_relief_m": 24,
|
||||
"micro_habitat_distinct": 2
|
||||
},
|
||||
"coherence": {
|
||||
"water_districts": 49,
|
||||
"water_districts_wet": 44,
|
||||
"water_districts": 46,
|
||||
"water_districts_wet": 46,
|
||||
"drainage_samples": 0,
|
||||
"drainage_monotonic": 0,
|
||||
"vegetation_samples": 64,
|
||||
"vegetated_districts": 8
|
||||
"vegetated_districts": 7
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -49,32 +49,32 @@
|
||||
"moisture_q": {
|
||||
"min": 0,
|
||||
"max": 20,
|
||||
"distinct": 21
|
||||
"distinct": 20
|
||||
},
|
||||
"elev_q": {
|
||||
"min": 0,
|
||||
"max": 99,
|
||||
"distinct": 100
|
||||
"max": 100,
|
||||
"distinct": 101
|
||||
},
|
||||
"slope_q": {
|
||||
"min": 0,
|
||||
"max": 6,
|
||||
"distinct": 7
|
||||
"max": 15,
|
||||
"distinct": 14
|
||||
},
|
||||
"ocean_fraction_q": {
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"distinct": 65
|
||||
"distinct": 38
|
||||
},
|
||||
"morphology_zones": 9,
|
||||
"vegetation_classes": 1,
|
||||
"terrain_materials": 4,
|
||||
"voxel_relief_m": 19,
|
||||
"morphology_zones": 6,
|
||||
"vegetation_classes": 2,
|
||||
"terrain_materials": 2,
|
||||
"voxel_relief_m": 21,
|
||||
"micro_habitat_distinct": 0
|
||||
},
|
||||
"coherence": {
|
||||
"water_districts": 25,
|
||||
"water_districts_wet": 22,
|
||||
"water_districts": 19,
|
||||
"water_districts_wet": 19,
|
||||
"drainage_samples": 0,
|
||||
"drainage_monotonic": 0,
|
||||
"vegetation_samples": 64,
|
||||
|
||||
Reference in New Issue
Block a user