Files
settled-reach/server/src/atlas/detail_scatter.rs
T
jpmschweitzerandClaude Fable 5 fef422bf01 feat(simulation): T-1125/T-1126 coastline invention — two-tier character model, shared invent_primitives, Marine vegetation
T-1125: new atlas/coast_invention.rs — BodyCoastEnvelope (tier 1: erosion
maturity derived hydro×atmo, tectonic energy; D-240 stands, no orbital
inputs) + coast_character_at (tier 2: latitude, glaciation fjord response,
local wetness, seeded 100-400 km heterogeneity field). C1 multi-octave
warp ~16-262 km, sub-pixel cap 0.75 px, salted stream isolated from
terrain scatter and climate edge-fuzz.

district_profile: shared invent_primitives front-half (warped envelope
sampling + slope-independent scatter floor + shoreline carving) wired
into BOTH derive_district and derive_district_profile — the batch path's
scatter-free area-mean aggregate is replaced by an invented centre-point
sample of the same continuous field, making silent path divergence
structurally impossible.

T-1126: VegetationClass::Marine appended at discriminant 6, verdict
morphology-derived (open_water from OpenOcean|Lake — no fourth ocean
threshold); airless precedence preserved. voxel.rs exhaustive-match arm
documented unreachable (WaterBody owns ocean districts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 08:21:07 +02:00

319 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Detail-scatter synthesis (D-243 §2) — the mid-scale terrain (≈2–40 km) that
//! the coarse heightmap (~40–78 km/pixel) is too coarse to carry and the voxel
//! generators (<2 km, inside a district) are too fine to reach. This is the
//! "stretch magic" that fills the gap so an interpolated heightmap reads as real
//! terrain instead of smooth ramps between samples.
//!
//! **Character (locked, T-1077):**
//! - **Adaptive** — ruggedness follows the local slope/morphology: gentle flats
//! stay soft (low amplitude, rolling), rugged highs gain ridge/valley energy.
//! - **Envelope + modulation** — the heightmap's local relief is the *ceiling* on
//! amplitude (smooth where the authored surface is smooth, so a lore body's
//! plain never sprouts a mountain), and morphology *modulates the character*
//! within that ceiling.
//!
//! **Determinism (D-227 / D-010):** a pure, seed+position-keyed value-noise
//! function — no stored state, no RNG, no platform-variant lookup. The output is
//! an `f64` *positional* perturbation; structural decisions downstream quantise it
//! to integer `slope_q`/`elev_q` (the truncate-before-decision convention).
use crate::seed::splitmix64;
/// Mid-scale octave wavelengths in metres — the 2–40 km band. Coarsest first.
/// Below the finest (~4 km) the district→voxel layers own the detail; above the
/// coarsest (~33 km) the heightmap itself carries the shape.
const OCTAVE_WAVELENGTHS_M: [f64; 4] = [32_768.0, 16_384.0, 8_192.0, 4_096.0];
/// Voxel-tier octave wavelengths in metres — the ≈0.13–1 km **sub-district** band
/// (all finer than the 2 km district planning unit) that the district-tier
/// [`terrain_detail`] (4–40 km) is too coarse to carry and the per-voxel scatter
/// (<64 m) is too fine to reach. This is the [`voxel_relief`] band: the rolling/ridged
/// hills a *walking character* navigates by (T-1081). The coarsest octave stays below
/// the district size so the relief never competes with `elev_q`'s district-scale role.
const VOXEL_OCTAVE_WAVELENGTHS_M: [f64; 4] = [1_024.0, 512.0, 256.0, 128.0];
/// Sub-chunk mosaic octave wavelengths in metres — the ≈8–64 m band, finer than the
/// [`VOXEL_OCTAVE_WAVELENGTHS_M`] sub-district band, so a micro-habitat patch reads as
/// a distinct blob *inside* a single 64 m chunk (T-1084, D-246). Coarsest first.
const MOSAIC_OCTAVE_WAVELENGTHS_M: [f64; 4] = [64.0, 32.0, 16.0, 8.0];
/// Deterministic lattice value in `[-1, 1)` for an integer noise cell.
#[inline]
fn lattice(seed: u64, ix: i64, iy: i64) -> f64 {
let mixed = (ix as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)
^ (iy as u64).wrapping_mul(0xBF58_476D_1CE4_E5B9);
let h = splitmix64(seed ^ splitmix64(mixed));
// Top 53 bits → [0, 1) → [-1, 1).
((h >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
}
/// Smooth (C¹) value-noise sample in `[-1, 1]` at world `(wx, wy)` for one
/// wavelength. Smoothstep interpolation keeps lattice cell boundaries crease-free.
/// `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());
let (tx, ty) = (fx - x0, fy - y0);
let sx = tx * tx * (3.0 - 2.0 * tx);
let sy = ty * ty * (3.0 - 2.0 * ty);
let (ix, iy) = (x0 as i64, y0 as i64);
let c00 = lattice(seed, ix, iy);
let c10 = lattice(seed, ix + 1, iy);
let c01 = lattice(seed, ix, iy + 1);
let c11 = lattice(seed, ix + 1, iy + 1);
let a = c00 + (c10 - c00) * sx;
let b = c01 + (c11 - c01) * sx;
a + (b - a) * sy
}
/// The adaptive mid-scale elevation perturbation in `[0,1]`-normalized units (the
/// same space as `elev_pct`); multiply by the body's elevation span for metres.
///
/// - `wx`, `wy`: world-metre coordinates (absolute; keyed so features are
/// position-stable, never folded into a chunk frame).
/// - `envelope` (0–1): the heightmap's local relief — the **amplitude ceiling**.
/// - `ruggedness` (0–1): from local slope/morphology — **adaptive** ridge energy.
///
/// Returns roughly `[-envelope, +envelope]`, smaller and smoother as `ruggedness`
/// drops toward 0 (gentle flats), larger and ridged as it rises toward 1.
pub fn terrain_detail(seed: u64, wx: f64, wy: f64, envelope: f64, ruggedness: f64) -> f64 {
enveloped_fbm(seed, wx, wy, envelope, ruggedness, &OCTAVE_WAVELENGTHS_M)
}
/// The voxel-tier mid-scale relief perturbation in `[0,1]`-normalized units
/// (multiply by the body's relief span for metres) — the ≈0.25–2 km rolling/ridged
/// hills a walking character navigates by (T-1081). Identical envelope/ruggedness
/// contract as [`terrain_detail`]; only the octave band differs
/// ([`VOXEL_OCTAVE_WAVELENGTHS_M`] vs the district [`OCTAVE_WAVELENGTHS_M`]).
///
/// Seed must be **body-global** (constant across the body) — the position
/// `(wx, wy)` carries the variation. A per-voxel seed would make every voxel a
/// fresh lattice (white noise, not smooth hills).
pub fn voxel_relief(seed: u64, wx: f64, wy: f64, envelope: f64, ruggedness: f64) -> f64 {
enveloped_fbm(
seed,
wx,
wy,
envelope,
ruggedness,
&VOXEL_OCTAVE_WAVELENGTHS_M,
)
}
/// The sub-chunk micro-habitat **selection field** in `[0, 1]` — a smooth,
/// body-global, position-keyed multi-octave value-noise scalar the mosaic pass
/// thresholds against a per-class palette's cumulative weights (T-1084, D-246).
///
/// Unlike [`voxel_relief`] this is not an elevation perturbation: it carries no
/// envelope and no ridge folding — it is a plain fBm remapped to `[0, 1]` whose only
/// job is to pick *which* micro-habitat a voxel lands in, as spatially-coherent blobs
/// (low-frequency → patches, never per-tile salt-and-pepper). The seed must be
/// **body-global** (constant across the body); the position `(wx, wy)` carries the
/// variation, exactly as for [`voxel_relief`].
pub fn voxel_mosaic(seed: u64, wx: f64, wy: f64) -> f64 {
let mut sum = 0.0;
let mut amp = 1.0;
let mut norm = 0.0;
for (i, &wl) in MOSAIC_OCTAVE_WAVELENGTHS_M.iter().enumerate() {
let n = value_noise(
seed.wrapping_add((i as u64).wrapping_mul(0x1000)),
wx,
wy,
wl,
);
sum += n * amp;
norm += amp;
amp *= 0.5;
}
let fbm = sum / norm; // ≈ [-1, 1]
((fbm + 1.0) * 0.5).clamp(0.0, 1.0)
}
/// Shared adaptive-fBm core for [`terrain_detail`] and [`voxel_relief`] — the only
/// difference between the two tiers is the octave wavelength band. Returns the
/// enveloped, ruggedness-modulated perturbation (roughly `[-envelope, +envelope]`).
fn enveloped_fbm(
seed: u64,
wx: f64,
wy: f64,
envelope: f64,
ruggedness: f64,
wavelengths: &[f64],
) -> f64 {
let env = envelope.clamp(0.0, 1.0);
let rug = ruggedness.clamp(0.0, 1.0);
if env == 0.0 {
return 0.0; // perfectly flat heightmap → no invented relief (envelope rule)
}
let mut sum = 0.0;
let mut amp = 1.0;
let mut norm = 0.0;
for (i, &wl) in wavelengths.iter().enumerate() {
let mut n = value_noise(
seed.wrapping_add((i as u64).wrapping_mul(0x1000)),
wx,
wy,
wl,
);
// Ridged folding blends in with ruggedness: flats stay smooth (rolling
// fBm), highs fold to sharp ridges/incised valleys (modulation).
if rug > 0.0 {
let ridged = 1.0 - 2.0 * n.abs(); // ridge crest at zero-crossings
n = n * (1.0 - rug) + ridged * rug;
}
sum += n * amp;
norm += amp;
// Higher octaves gain weight in rugged zones (rough detail); flats damp
// them toward a single gentle swell.
amp *= 0.5 + 0.35 * rug;
}
let fbm = sum / norm; // ≈ [-1, 1]
// Envelope caps the amplitude; within the cap, ruggedness scales how much of
// the headroom is used (flats use ~40%, peaks ~100%).
fbm * env * (0.4 + 0.6 * rug)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deterministic() {
let a = terrain_detail(42, 12_345.0, -6_789.0, 0.6, 0.5);
let b = terrain_detail(42, 12_345.0, -6_789.0, 0.6, 0.5);
assert_eq!(a, b);
}
#[test]
fn flat_envelope_invents_nothing() {
// envelope = 0 → the authored heightmap is flat here → no relief (the
// envelope rule: never sprout terrain on an authored plain).
for &rug in &[0.0, 0.5, 1.0] {
assert_eq!(terrain_detail(7, 1000.0, 2000.0, 0.0, rug), 0.0);
}
}
#[test]
fn bounded_by_envelope() {
// Output magnitude never exceeds the envelope ceiling, anywhere.
for i in 0..400 {
let wx = (i as f64) * 137.0;
let wy = (i as f64) * -91.0;
let v = terrain_detail(99, wx, wy, 0.5, 1.0);
assert!(v.abs() <= 0.5 + 1e-9, "v={v} exceeded envelope at {i}");
}
}
#[test]
fn rugged_is_higher_amplitude_than_gentle() {
// Adaptive: averaged |relief| under high ruggedness exceeds that under low
// ruggedness for the same envelope (gentle flats vs rugged highs).
let mean_abs = |rug: f64| -> f64 {
let n = 500;
(0..n)
.map(|i| terrain_detail(3, i as f64 * 53.0, i as f64 * 71.0, 0.7, rug).abs())
.sum::<f64>()
/ n as f64
};
assert!(
mean_abs(0.9) > mean_abs(0.1),
"rugged terrain must carry more relief than gentle terrain"
);
}
#[test]
fn continuous_no_creases() {
// Small position steps produce small output changes (C¹ value noise) — no
// lattice creases that would read as grid artifacts.
let base = terrain_detail(11, 5_000.0, 5_000.0, 0.8, 0.6);
let near = terrain_detail(11, 5_000.5, 5_000.0, 0.8, 0.6);
assert!(
(base - near).abs() < 0.05,
"0.5 m step jumped by {}",
(base - near).abs()
);
}
// ── voxel_relief (T-1081): same contract, sub-district band ──────────────
#[test]
fn voxel_relief_deterministic_and_bounded() {
let a = voxel_relief(42, 12_345.0, -6_789.0, 0.6, 0.5);
let b = voxel_relief(42, 12_345.0, -6_789.0, 0.6, 0.5);
assert_eq!(a, b);
// Same envelope rule + amplitude ceiling as terrain_detail.
assert_eq!(voxel_relief(7, 1_000.0, 2_000.0, 0.0, 1.0), 0.0);
for i in 0..400 {
let v = voxel_relief(99, i as f64 * 137.0, i as f64 * -91.0, 0.5, 1.0);
assert!(v.abs() <= 0.5 + 1e-9, "v={v} exceeded envelope at {i}");
}
}
#[test]
fn voxel_relief_varies_at_sub_district_scale() {
// The voxel band must produce DISTINCT relief within a single 2 km district —
// exactly the variation terrain_detail's 4–40 km band cannot carry, and the
// flatness T-1081 fixes. Body-global seed; position carries the variation. Steps
// are deliberately non-aligned with the octave wavelengths to avoid aliasing.
let seed = 1234;
let vals: Vec<f64> = (0..16)
.map(|i| voxel_relief(seed, i as f64 * 137.0, i as f64 * 89.0, 0.7, 0.6))
.collect();
let min = vals.iter().cloned().fold(f64::INFINITY, f64::min);
let max = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
assert!(
max - min > 0.1,
"voxel relief is near-flat across a district: range {}",
max - min
);
}
// ── voxel_mosaic (T-1084): sub-chunk selection field ─────────────────────
#[test]
fn voxel_mosaic_deterministic_and_in_range() {
let a = voxel_mosaic(42, 12_345.0, -6_789.0);
let b = voxel_mosaic(42, 12_345.0, -6_789.0);
assert_eq!(a, b);
for i in 0..400 {
let v = voxel_mosaic(99, i as f64 * 137.0, i as f64 * -91.0);
assert!((0.0..=1.0).contains(&v), "v={v} out of [0,1] at {i}");
}
}
#[test]
fn voxel_mosaic_varies_at_sub_chunk_scale() {
// The mosaic must produce DISTINCT values within a single 64 m chunk — the
// blob scale that reads as patches, not the district-scale voxel_relief band.
// Steps stay well under 64 m and non-aligned to the octave wavelengths.
let seed = 7;
let vals: Vec<f64> = (0..16)
.map(|i| voxel_mosaic(seed, i as f64 * 3.7, i as f64 * 2.9))
.collect();
let min = vals.iter().cloned().fold(f64::INFINITY, f64::min);
let max = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
assert!(
max - min > 0.1,
"mosaic field is near-flat inside a chunk: range {}",
max - min
);
}
#[test]
fn voxel_mosaic_is_smooth_not_salt_and_pepper() {
// C¹ value noise → a small position step is a small field change, so patches
// are coherent blobs, never the per-tile scatter the mosaic replaces.
let base = voxel_mosaic(11, 5_000.0, 5_000.0);
let near = voxel_mosaic(11, 5_000.5, 5_000.0);
assert!(
(base - near).abs() < 0.05,
"0.5 m step jumped by {}",
(base - near).abs()
);
}
}