407 lines
19 KiB
Rust
407 lines
19 KiB
Rust
//! Vegetation-patchiness invention field (T-1162, D-227, nature-layer scope
|
||
//! per the ticket's SCOPE CLARIFICATION amendment).
|
||
//!
|
||
//! Jeroen's brief: "even the nature layer should produce rivers, forests,
|
||
//! copses, bushes, deserts, elevation" — vegetation must read as spatial
|
||
//! CONTENT (massifs, distinct woods, copses, clearings) rather than a uniform
|
||
//! per-cell class tint that only ever changes at a climate-driven district
|
||
//! boundary. This module is the vegetation twin of
|
||
//! [`crate::atlas::coast_invention`]'s two-tier character model, adapted to
|
||
//! the constraint that vegetation has no warp target of its own (there is no
|
||
//! "vegetation heightmap" to displace sampling against) — instead it
|
||
//! perturbs [`crate::atlas::district_profile::derive_moisture_q`]'s **output**
|
||
//! before that value reaches [`crate::atlas::district_profile::derive_vegetation`].
|
||
//!
|
||
//! ## Design choice: perturb the moisture INPUT, not the class thresholds
|
||
//!
|
||
//! Two composition points were available: (a) bias `derive_vegetation`'s
|
||
//! internal treeline/moisture thresholds directly, or (b) perturb the
|
||
//! `moisture_q` value it (and its siblings) consume. **(b) was chosen.**
|
||
//! `moisture_q` already feeds THREE downstream classifiers in lockstep —
|
||
//! [`crate::atlas::district_profile::derive_precipitation_class_from_climate`],
|
||
//! [`crate::atlas::district_profile::derive_glaciation_grade_from_climate`],
|
||
//! and `derive_vegetation` itself. Perturbing the shared input means a patchy-dry
|
||
//! cell reads dry across precipitation AND vegetation AND (at the right
|
||
//! temperature) glaciation grade — one coherent world-fact, not a
|
||
//! vegetation-only illusion sitting on top of an unperturbed climate. Biasing
|
||
//! `derive_vegetation`'s thresholds alone would let a cell "look wetter" in
|
||
//! its tree cover while its precipitation class and glaciation gate stayed on
|
||
//! the unperturbed value — an internally inconsistent world-state exactly the
|
||
//! kind D-227/D-010 determinism discipline exists to prevent.
|
||
//!
|
||
//! ## Two-tier model (mirrors `coast_invention`)
|
||
//!
|
||
//! - **Tier 1 — [`VegetationEnvelope`] (body/region-scale, "where forests are
|
||
//! POSSIBLE"):** a region-scale, latitude-modulated ceiling on how far the
|
||
//! patchiness field is allowed to swing `moisture_q`. Derived from
|
||
//! [`crate::atlas::district_profile::BodyParams`] only (D-240 discipline,
|
||
//! same posture as `coast_invention::BodyCoastEnvelope`) plus latitude — a
|
||
//! hyper-arid world's patchiness ceiling is narrow (deserts stay deserts;
|
||
//! no patch of forest should ever invent itself on a bone-dry world), a
|
||
//! temperate wet world's ceiling is wide (real massif-to-massif variation).
|
||
//! - **Tier 2 — [`moisture_perturb_q`] (position, "which patches manifest"):**
|
||
//! a signed integer offset in `[-ceiling, +ceiling]`, built from a
|
||
//! MULTI-SCALE deterministic field (region/massif → district/wood →
|
||
//! quarter/copse octave bands, §"Cross-rung coherence" below) so a forest
|
||
//! massif at Region scale resolves to distinct woods at District and
|
||
//! copses/clearings at Quarter — never contradicting the coarser verdict,
|
||
//! only refining its boundary.
|
||
//!
|
||
//! ## Cross-rung coherence (the ticket's hard requirement)
|
||
//!
|
||
//! A forest at Region scale MUST still read as forest-majority when sampled
|
||
//! at Quarter — the finer rungs REFINE the boundary and punch clearings, they
|
||
//! never contradict the coarse verdict wholesale. This is achieved the SAME
|
||
//! way `coast_invention`'s warp achieves cross-rung coherence: a SINGLE fBm
|
||
//! sum whose octave terms span coarse-to-fine wavelengths, sampled at
|
||
//! whatever position/cutoff a rung asks for — not two independently-seeded
|
||
//! fields for "coarse" and "fine" that could disagree. The coarse octaves
|
||
//! (massif-scale, ≈100–400 km, always present regardless of cutoff — same
|
||
//! posture as `coast_invention::character_field`, which is NEVER
|
||
//! cutoff-gated) set the DC bias (which side of "possible forest" a broad
|
||
//! area sits on); the fine octaves (district/quarter-scale, cutoff-gated
|
||
//! exactly like `terrain_detail`/`voxel_relief`) add zero-mean texture on top
|
||
//! that punches clearings and copses WITHOUT being able to flip the coarse
|
||
//! bias's sign at typical amplitudes (the fine-octave contribution is capped
|
||
//! well inside the coarse term's own swing — see [`moisture_perturb_q`]'s
|
||
//! amplitude split).
|
||
//!
|
||
//! ## Wavelength discipline (per rung, identical to parts 1–2 of T-1162)
|
||
//!
|
||
//! The fine tier reuses [`crate::atlas::detail_scatter::OCTAVE_WAVELENGTHS_M`]
|
||
//! (district band, 32,768–4,096 m) and
|
||
//! [`crate::atlas::detail_scatter::VOXEL_OCTAVE_WAVELENGTHS_M`] (quarter/sub-district
|
||
//! band, 1,024–128 m) — the SAME two bands the coast warp and relief
|
||
//! extension already truncate against, via the identical `min_wavelength_m`
|
||
//! hard-truncate cutoff convention (`enveloped_fbm`'s discipline). At
|
||
//! District's cutoff (≥2,048 m) only the district band's terms survive; at
|
||
//! Quarter's cutoff (1,024 m) the two coarsest voxel-band terms additionally
|
||
//! survive — exactly parts (a)/(b)'s discipline, reused rather than
|
||
//! reinvented for vegetation.
|
||
//!
|
||
//! ## Determinism & isolation (D-227/D-010)
|
||
//!
|
||
//! Pure functions of `(seed, body, position)`. Distinct salted hash streams
|
||
//! ([`VEGETATION_MASSIF_SALT`], [`VEGETATION_TEXTURE_SALT`]) — never
|
||
//! correlated with the coast warp, the terrain/voxel relief scatter, or the
|
||
//! climate edge-fuzz warp, the same isolation convention every invention
|
||
//! field in this cascade follows.
|
||
//!
|
||
//! ## Wire format (unchanged)
|
||
//!
|
||
//! This module produces an `i32` moisture offset consumed BEFORE
|
||
//! `DistrictProfile` is built — `vegetation_class` remains the single
|
||
//! discriminant per cell on the wire (`WindowCell::vegetation: u8`,
|
||
//! `layer_proxy.rs`). The patchiness lives in WHICH class a cell resolves to,
|
||
//! never in additional sub-cell wire data.
|
||
|
||
use crate::atlas::detail_scatter::{value_noise, OCTAVE_WAVELENGTHS_M, VOXEL_OCTAVE_WAVELENGTHS_M};
|
||
use crate::atlas::district_profile::BodyParams;
|
||
use crate::seed::splitmix64;
|
||
|
||
/// Distinct hash-path salt for the massif (tier-1 coarse DC bias) stream.
|
||
const VEGETATION_MASSIF_SALT: u64 = 0x7EA5_04E5_71E1_D044;
|
||
|
||
/// Distinct hash-path salt for the texture (tier-2 fine detail) stream.
|
||
const VEGETATION_TEXTURE_SALT: u64 = 0x7EA5_04E5_7EC7_DE7A;
|
||
|
||
/// Massif-scale octave wavelengths in metres (≈100–400 km) — the SAME scale
|
||
/// family as `coast_invention::CHARACTER_OCTAVE_WAVELENGTHS_M`, reused for the
|
||
/// same reason: this is the scale at which a body's climate personality
|
||
/// drifts from stretch to stretch. Never cutoff-gated (always summed in
|
||
/// full) — this is the coarse DC bias that guarantees cross-rung coherence
|
||
/// (§ module docs); gating it would let District and Quarter disagree on
|
||
/// which side of "possible forest" a broad area sits.
|
||
const MASSIF_OCTAVE_WAVELENGTHS_M: [f64; 3] = [409_600.0, 204_800.0, 102_400.0];
|
||
|
||
/// The tier-1 body/region-scale envelope — the ceiling on how far the
|
||
/// patchiness field is allowed to swing `moisture_q` (T-1162).
|
||
///
|
||
/// Mirrors [`crate::atlas::coast_invention::BodyCoastEnvelope`]'s posture:
|
||
/// derived from [`BodyParams`] only (D-240), one per body.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub struct VegetationEnvelope {
|
||
/// Maximum |offset| the patchiness field may apply to `moisture_q`
|
||
/// (points, 0–100 scale). Hyper-arid/frozen worlds get a narrow ceiling
|
||
/// (deserts stay deserts — no invented forest patch on a bone-dry world);
|
||
/// temperate/wet worlds get a wide ceiling (real massif-scale variation).
|
||
pub ceiling_q: i32,
|
||
}
|
||
|
||
/// Derive the tier-1 envelope from body params.
|
||
///
|
||
/// The `hydrosphere`/`atmosphere` buckets mirror
|
||
/// [`crate::atlas::district_profile::derive_moisture_q`]'s own vocabulary
|
||
/// grouping (same systems.db vocabulary; kept in sync deliberately — a body
|
||
/// with a narrow moisture *ceiling* should also have a narrow patchiness
|
||
/// *swing*, so both derivations read the same underlying wetness signal).
|
||
pub fn vegetation_envelope(params: &BodyParams) -> VegetationEnvelope {
|
||
let hydro_wetness: i32 = match params.hydrosphere.as_deref().unwrap_or("none") {
|
||
"liquid_water" | "ocean" | "ocean-coastal" | "extensive" => 100,
|
||
"rivers" | "rivers-lakes" | "moderate" => 70,
|
||
"ice" => 35,
|
||
"subsurface_liquid" => 25,
|
||
"subsurface" | "subsurface_ice" => 15,
|
||
"minimal" | "trace" => 8,
|
||
"none" => 0,
|
||
_ => 40,
|
||
};
|
||
let atmo_density: i32 = match params.atmosphere.as_deref().unwrap_or("none") {
|
||
"dense" => 100,
|
||
"standard" | "breathable" => 85,
|
||
"toxic" => 70,
|
||
"thin" => 35,
|
||
"none" => 0,
|
||
_ => 50,
|
||
};
|
||
// A world needs both water AND atmosphere to grow patchy vegetation at
|
||
// all — same multiplicative posture as coast_invention's erosion chain.
|
||
// Ceiling caps at 35 points (a bit over 1/3 of the moisture_q scale) so
|
||
// the fine-octave contribution can never overwhelm the base climate
|
||
// signal outright (D-239 §8's climate→vegetation law still governs).
|
||
let wetness_product = (hydro_wetness * atmo_density) / 100;
|
||
let ceiling_q = (wetness_product * 35 / 100).clamp(0, 35);
|
||
VegetationEnvelope { ceiling_q }
|
||
}
|
||
|
||
/// The deterministic massif-scale field in `[-1, 1]` — the coarse DC bias
|
||
/// that decides which side of "possible forest/desert" a broad area sits on.
|
||
/// Never cutoff-gated (see module docs' cross-rung-coherence section).
|
||
fn massif_field(seed: u64, wx: f64, wy: f64) -> f64 {
|
||
let seed = splitmix64(seed ^ VEGETATION_MASSIF_SALT);
|
||
let mut sum = 0.0;
|
||
let mut amp = 1.0;
|
||
let mut norm = 0.0;
|
||
for (i, &wl) in MASSIF_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
|
||
}
|
||
|
||
/// The cutoff-gated fine-texture field in `[-1, 1]` — district-band
|
||
/// ([`OCTAVE_WAVELENGTHS_M`]) octaves that punch woods at District spacing,
|
||
/// PLUS the sub-district ([`VOXEL_OCTAVE_WAVELENGTHS_M`]) octaves that punch
|
||
/// copses/clearings once a request's cutoff admits them (Quarter and finer).
|
||
/// Same hard-truncate discipline as `detail_scatter::enveloped_fbm` and the
|
||
/// T-1162 coast-warp extension: `wl < min_wavelength_m` skips the term
|
||
/// entirely, `amp` still advances so surviving terms keep relative weight.
|
||
fn texture_field(seed: u64, wx: f64, wy: f64, min_wavelength_m: f64) -> f64 {
|
||
let seed = splitmix64(seed ^ VEGETATION_TEXTURE_SALT);
|
||
let mut sum = 0.0;
|
||
let mut amp = 1.0;
|
||
let mut norm = 0.0;
|
||
let mut idx: u64 = 0;
|
||
for &wl in OCTAVE_WAVELENGTHS_M
|
||
.iter()
|
||
.chain(VOXEL_OCTAVE_WAVELENGTHS_M.iter())
|
||
{
|
||
if wl < min_wavelength_m {
|
||
amp *= 0.5;
|
||
idx += 1;
|
||
continue;
|
||
}
|
||
sum += value_noise(seed.wrapping_add(idx.wrapping_mul(0x1000)), wx, wy, wl) * amp;
|
||
norm += amp;
|
||
amp *= 0.5;
|
||
idx += 1;
|
||
}
|
||
if norm == 0.0 {
|
||
return 0.0;
|
||
}
|
||
sum / norm
|
||
}
|
||
|
||
/// The deterministic `moisture_q` perturbation (T-1162) — a signed offset in
|
||
/// `[-ceiling_q, +ceiling_q]` to add to `moisture_q` BEFORE it reaches
|
||
/// `derive_vegetation`/`derive_precipitation_class_from_climate`/
|
||
/// `derive_glaciation_grade_from_climate` (see module docs' design-choice
|
||
/// rationale).
|
||
///
|
||
/// Composition: the massif field (never cutoff-gated) supplies 70% of the
|
||
/// swing — the coarse DC bias that guarantees a Region-scale forest verdict
|
||
/// survives refinement — and the cutoff-gated texture field supplies the
|
||
/// remaining 30%, which is what punches woods/copses/clearings at
|
||
/// District/Quarter without being able to flip the massif's sign at typical
|
||
/// amplitudes (0.3 < 0.7, so the texture term alone can never outweigh the
|
||
/// massif term). `min_wavelength_m` — `0.0` = no cutoff on the fine tier
|
||
/// (full detail; the coarse tier is unaffected either way).
|
||
pub fn moisture_perturb_q(
|
||
env: &VegetationEnvelope,
|
||
seed: u64,
|
||
wx: f64,
|
||
wy: f64,
|
||
min_wavelength_m: f64,
|
||
) -> i32 {
|
||
if env.ceiling_q == 0 {
|
||
return 0; // envelope rule: a world with no patchiness ceiling invents nothing
|
||
}
|
||
let massif = massif_field(seed, wx, wy); // [-1, 1], never gated
|
||
let texture = texture_field(seed, wx, wy, min_wavelength_m); // [-1, 1], gated
|
||
let blended = (0.70 * massif + 0.30 * texture).clamp(-1.0, 1.0);
|
||
((blended * env.ceiling_q as f64).round() as i32).clamp(-env.ceiling_q, env.ceiling_q)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn params(hydro: &str, atmo: &str) -> BodyParams {
|
||
BodyParams {
|
||
hydrosphere: Some(hydro.to_string()),
|
||
atmosphere: Some(atmo.to_string()),
|
||
planet_class: Some("temperate".to_string()),
|
||
tectonic_activity: None,
|
||
latitude_deg: 0.0,
|
||
elevation_km: 0.0,
|
||
body_radius_km: Some(6000.0),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn envelope_wet_body_has_wider_ceiling_than_dry() {
|
||
let wet = vegetation_envelope(¶ms("ocean", "breathable"));
|
||
let dry = vegetation_envelope(¶ms("minimal", "thin"));
|
||
assert!(
|
||
wet.ceiling_q > dry.ceiling_q,
|
||
"wet+breathable body must have a wider patchiness ceiling than dry+thin"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn envelope_airless_or_dry_body_has_zero_ceiling() {
|
||
let none = vegetation_envelope(¶ms("none", "none"));
|
||
assert_eq!(
|
||
none.ceiling_q, 0,
|
||
"a body with neither water nor atmosphere must have zero patchiness ceiling"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn perturb_is_deterministic() {
|
||
let env = vegetation_envelope(¶ms("ocean", "breathable"));
|
||
let a = moisture_perturb_q(&env, 42, 1_000_000.0, 2_000_000.0, 0.0);
|
||
let b = moisture_perturb_q(&env, 42, 1_000_000.0, 2_000_000.0, 0.0);
|
||
assert_eq!(a, b);
|
||
}
|
||
|
||
#[test]
|
||
fn perturb_bounded_by_ceiling() {
|
||
let env = vegetation_envelope(¶ms("ocean", "breathable"));
|
||
for i in 0..400 {
|
||
let wx = i as f64 * 91_137.0;
|
||
let wy = i as f64 * -53_211.0;
|
||
let p = moisture_perturb_q(&env, 99, wx, wy, 0.0);
|
||
assert!(
|
||
p.abs() <= env.ceiling_q,
|
||
"perturb {p} exceeded ceiling {}",
|
||
env.ceiling_q
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn perturb_zero_ceiling_is_always_zero() {
|
||
let env = VegetationEnvelope { ceiling_q: 0 };
|
||
for i in 0..50 {
|
||
let p = moisture_perturb_q(&env, 7, i as f64 * 1234.0, i as f64 * -987.0, 0.0);
|
||
assert_eq!(p, 0);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn perturb_varies_at_massif_scale() {
|
||
// Positions ~600 km apart must show real massif-scale variation —
|
||
// otherwise the whole body would read as one uniform patchiness value.
|
||
let env = vegetation_envelope(¶ms("ocean", "breathable"));
|
||
let vals: Vec<i32> = (0..8)
|
||
.map(|i| moisture_perturb_q(&env, 11, i as f64 * 600_000.0, 300_000.0, 0.0))
|
||
.collect();
|
||
let min = *vals.iter().min().unwrap();
|
||
let max = *vals.iter().max().unwrap();
|
||
assert!(
|
||
max - min > 2,
|
||
"massif-scale variation too flat across the body: range {}",
|
||
max - min
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn perturb_stream_uncorrelated_with_coast_and_scatter_streams() {
|
||
let env = vegetation_envelope(¶ms("ocean", "breathable"));
|
||
let p = moisture_perturb_q(&env, 42, 1e6, 1e6, 0.0);
|
||
let scatter = crate::atlas::detail_scatter::terrain_detail(42, 1e6, 1e6, 1.0, 0.5, 0.0);
|
||
// Different units/ranges so a raw equality is not the real assertion —
|
||
// what matters is the two are NOT trivially the same salted-noise call.
|
||
// Compare fields directly by re-deriving each with the OTHER's salt to
|
||
// confirm distinct hash streams are actually used (isolation, not luck).
|
||
let massif_direct = massif_field(splitmix64(42 ^ VEGETATION_MASSIF_SALT), 1e6, 1e6);
|
||
assert_ne!(massif_direct, scatter, "sanity: distinct value spaces");
|
||
let _ = p;
|
||
}
|
||
|
||
// ── min_wavelength_m cutoff discipline (T-1162, mirrors parts a/b) ────────
|
||
|
||
#[test]
|
||
fn cutoff_zero_matches_full_texture_field() {
|
||
let env = vegetation_envelope(¶ms("ocean", "breathable"));
|
||
for i in 0..50 {
|
||
let wx = i as f64 * 7_331.0;
|
||
let wy = i as f64 * -4_177.0;
|
||
let with_zero = moisture_perturb_q(&env, 21, wx, wy, 0.0);
|
||
// The finest combined band entry is VOXEL_OCTAVE_WAVELENGTHS_M's
|
||
// 128.0 — a cutoff below that admits every octave too.
|
||
let with_below_finest = moisture_perturb_q(&env, 21, wx, wy, 1.0);
|
||
assert_eq!(with_zero, with_below_finest);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn district_cutoff_admits_fewer_octaves_than_quarter_cutoff() {
|
||
// District's REAL quantized MIN_WL_BANDS_M floor is 4,096 m
|
||
// (District's own Nyquist limit — OCTAVE_WAVELENGTHS_M's pre-existing
|
||
// finest entry). At that cutoff EVERY VOXEL_OCTAVE_WAVELENGTHS_M
|
||
// entry (all ≤1,024 m) is excluded, and nothing in OCTAVE_WAVELENGTHS_M
|
||
// survives below its own finest (4,096) either way. Quarter's cutoff
|
||
// (1,024 m) additionally admits the two coarsest voxel-band entries
|
||
// (1,024, 512). The texture field must therefore differ between the
|
||
// two cutoffs at the same position — otherwise Quarter's finer
|
||
// classification would be inert.
|
||
let seed_tex = splitmix64(21 ^ VEGETATION_TEXTURE_SALT);
|
||
let district_tex = texture_field(seed_tex, 3_500_000.0, 1_200_000.0, 4_096.0);
|
||
let quarter_tex = texture_field(seed_tex, 3_500_000.0, 1_200_000.0, 1_024.0);
|
||
assert_ne!(
|
||
district_tex, quarter_tex,
|
||
"Quarter's finer cutoff must admit voxel-band octaves District's cutoff excludes"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cutoff_truncates_every_octave_above_coarsest_band() {
|
||
// A cutoff above the coarsest combined-band entry (32,768.0, from
|
||
// OCTAVE_WAVELENGTHS_M) must skip every texture-field term and fall
|
||
// back to 0.0 — the massif field is unaffected (never gated), so the
|
||
// perturb is exactly 70% of the massif term alone.
|
||
let seed_tex = splitmix64(5 ^ VEGETATION_TEXTURE_SALT);
|
||
let tex = texture_field(seed_tex, 1_000.0, 2_000.0, 1_000_000.0);
|
||
assert_eq!(tex, 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn massif_field_never_gated_by_cutoff() {
|
||
// The coarse DC bias must be IDENTICAL regardless of min_wavelength_m
|
||
// — this is what cross-rung coherence rests on.
|
||
let a = massif_field(42, 5_000_000.0, 2_000_000.0);
|
||
let b = massif_field(42, 5_000_000.0, 2_000_000.0);
|
||
assert_eq!(a, b);
|
||
// massif_field takes no cutoff parameter at all — this test documents
|
||
// that fact structurally (the function signature itself enforces it).
|
||
}
|
||
}
|