feat(simulation): voxel-tier mid-scale relief — navigable hills (T-1081)
The voxel tier read flat (the D-245 "0–3 m, no hills to navigate by" bug): family generators set elevation from elev_q at a compressed scale plus only ±4 m micro-scatter, and detail-scatter's mid-scale relief (D-243 §2) only ever reached the district tier (elev_q), never per-voxel elevation_m. - detail_scatter.rs: extract the enveloped-fBm core; add `voxel_relief` at the 0.25–2 km octave band (vs the district 4–40 km band). - voxel.rs: add the mid-scale relief post-dispatch in derive_voxel_column, body-global SeedDomain::VoxelRelief seed (position-keyed), f64 truncated to integer metres (D-010). Envelope = slope_q*3 + elev_q — the coarse heightmap gives slope_q ≈ 0 even on high ground (Arbour max 13), so elevation must drive ruggedness. Flat families only (Alluvial/Lava/BraidedDelta/Dune/Meander); the dramatic families already carry strong internal relief and WaterBody stays at sea level. - seed.rs: SeedDomain::VoxelRelief (D-224 domain separation). - believability.rs: new `contrast.voxel_relief_m` metric — mean within-district elevation range over a 2 km transect (a single 64 m sample chunk is narrower than the relief band) + a "voxel relief" criterion. Arbour @ yolo: voxel relief 5 m → 59 m, 6/8 → 7/8 criteria (remaining fail = the Q-123 vegetation-denominator item). Edict 59 m. Golden regenerated; 1586 lib tests + believability harness (determinism) pass; clippy -D warnings clean. D-239 amended (T-1081); Q-123 item 4 updated. Deferred to Q-123/follow-up: the provisional span + threshold, and a per-body hypsometric absolute-elevation model. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -67,6 +67,11 @@ const WATER_PRESENCE_Q: i32 = 10;
|
||||
const VOXEL_SAMPLE_DISTRICTS: usize = 64;
|
||||
/// Stride over a district's representative 64 m chunk (8 → an 8×8 = 64-voxel sample).
|
||||
const VOXEL_SAMPLE_STRIDE: usize = 8;
|
||||
/// Chunk offsets within a district (×64 m ⇒ 128 m … 1920 m) for the mid-scale relief
|
||||
/// transect (T-1081) — samples the elevation range a character crosses over the
|
||||
/// district's ~2 km span (the voxel-relief band), which the single 64 m sample chunk
|
||||
/// is too narrow to register.
|
||||
const RELIEF_TRANSECT_CHUNK_OFFSETS: [i32; 6] = [2, 8, 14, 20, 26, 30];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Report types (serde — golden-snapshot-able)
|
||||
@@ -101,6 +106,12 @@ pub struct ContrastMetrics {
|
||||
pub vegetation_classes: usize,
|
||||
/// Distinct voxel `TerrainMaterial` values across the voxel sample.
|
||||
pub terrain_materials: usize,
|
||||
/// Mean within-district voxel elevation *range* (metres) across a district-spanning
|
||||
/// transect of the sampled land districts (T-1081). The walkable-relief screen:
|
||||
/// near 0 on flat terrain (the "0–3 m, no hills" symptom), tens of metres once the
|
||||
/// voxel tier carries mid-scale relief. Measured over the 0.25–2 km band a character
|
||||
/// traverses — not the single 64 m sample chunk, which is too narrow to register it.
|
||||
pub voxel_relief_m: i32,
|
||||
}
|
||||
|
||||
/// Coherence checks — does the world render as a *caused* place? Pass/fail counts
|
||||
@@ -171,6 +182,7 @@ pub fn analyze(
|
||||
.map(|d| format!("{:?}", d.vegetation_class)),
|
||||
),
|
||||
terrain_materials: 0, // filled from the voxel sample below
|
||||
voxel_relief_m: 0, // filled from the relief transect below
|
||||
};
|
||||
|
||||
// ── Voxel-derived metrics over a deterministic seeded spread of districts ──
|
||||
@@ -181,6 +193,9 @@ pub fn analyze(
|
||||
let mut terrain_set: BTreeSet<String> = BTreeSet::new();
|
||||
let mut coh = CoherenceMetrics::default();
|
||||
let mut sampled = 0usize;
|
||||
// Mid-scale relief accumulators (T-1081): sum of per-district transect ranges over
|
||||
// land districts, and the count contributing, → mean within-district relief.
|
||||
let (mut relief_range_sum, mut relief_district_count) = (0i64, 0i64);
|
||||
|
||||
let keys: Vec<DistrictPos> = districts.keys().copied().collect();
|
||||
for idx in sample_indices(world_seed, keys.len(), VOXEL_SAMPLE_DISTRICTS) {
|
||||
@@ -243,8 +258,43 @@ pub fn analyze(
|
||||
coh.drainage_monotonic += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Mid-scale relief transect (T-1081): the dry-ground elevation range across the
|
||||
// district's ~2 km span — where the voxel tier's mid-scale relief lives. The
|
||||
// single 64 m sample chunk above is narrower than the relief's 0.25–2 km band,
|
||||
// so it would read flat even on rolling terrain; this transect is the screen.
|
||||
// Only land-dominant districts count (≥3 of 6 transect points dry) — coastal
|
||||
// districts with one stray dry point would otherwise contribute a 0 range and
|
||||
// bias the mean toward "flat" on an ocean world (a measurement artifact, not
|
||||
// flat land).
|
||||
let (mut relief_lo, mut relief_hi, mut relief_dry) = (i32::MAX, i32::MIN, 0i32);
|
||||
for &co in &RELIEF_TRANSECT_CHUNK_OFFSETS {
|
||||
let cpos = (
|
||||
dp.0 * CHUNKS_PER_DISTRICT + co,
|
||||
dp.1 * CHUNKS_PER_DISTRICT + co,
|
||||
);
|
||||
let cctx = derive_chunk_context(world_seed, body_id, prof, cpos, None);
|
||||
let tx = cpos.0 * CHUNK_M + CHUNK_M / 2;
|
||||
let ty = cpos.1 * CHUNK_M + CHUNK_M / 2;
|
||||
let col = derive_voxel_column(world_seed, body_id, prof, &cctx, tx, ty);
|
||||
if col.water == Water::Dry {
|
||||
relief_lo = relief_lo.min(col.elevation_m);
|
||||
relief_hi = relief_hi.max(col.elevation_m);
|
||||
relief_dry += 1;
|
||||
}
|
||||
}
|
||||
if relief_dry >= 3 {
|
||||
relief_range_sum += (relief_hi - relief_lo) as i64;
|
||||
relief_district_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let voxel_relief_m = if relief_district_count > 0 {
|
||||
(relief_range_sum / relief_district_count) as i32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
BelievabilityReport {
|
||||
body_id: body_id.to_string(),
|
||||
world_seed,
|
||||
@@ -252,6 +302,7 @@ pub fn analyze(
|
||||
voxel_sampled_districts: sampled,
|
||||
contrast: ContrastMetrics {
|
||||
terrain_materials: terrain_set.len(),
|
||||
voxel_relief_m,
|
||||
..contrast_scalar
|
||||
},
|
||||
coherence: coh,
|
||||
@@ -280,6 +331,11 @@ pub fn evaluate_criteria(r: &BelievabilityReport) -> Vec<Criterion> {
|
||||
pass: c.elev_q.spread() >= 10,
|
||||
detail: format!("elev_q spread={}", c.elev_q.spread()),
|
||||
},
|
||||
Criterion {
|
||||
name: "voxel relief",
|
||||
pass: c.voxel_relief_m >= 8,
|
||||
detail: format!("mean within-district relief {} m", c.voxel_relief_m),
|
||||
},
|
||||
Criterion {
|
||||
name: "morphology variety",
|
||||
pass: c.morphology_zones >= 2,
|
||||
|
||||
@@ -24,6 +24,12 @@ use crate::seed::splitmix64;
|
||||
/// 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.25–2 km band 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).
|
||||
const VOXEL_OCTAVE_WAVELENGTHS_M: [f64; 4] = [2_048.0, 1_024.0, 512.0, 256.0];
|
||||
|
||||
/// Deterministic lattice value in `[-1, 1)` for an integer noise cell.
|
||||
#[inline]
|
||||
fn lattice(seed: u64, ix: i64, iy: i64) -> f64 {
|
||||
@@ -64,6 +70,40 @@ fn value_noise(seed: u64, wx: f64, wy: f64, wavelength_m: f64) -> f64 {
|
||||
/// 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,
|
||||
)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -73,7 +113,7 @@ pub fn terrain_detail(seed: u64, wx: f64, wy: f64, envelope: f64, ruggedness: f6
|
||||
let mut sum = 0.0;
|
||||
let mut amp = 1.0;
|
||||
let mut norm = 0.0;
|
||||
for (i, &wl) in OCTAVE_WAVELENGTHS_M.iter().enumerate() {
|
||||
for (i, &wl) in wavelengths.iter().enumerate() {
|
||||
let mut n = value_noise(
|
||||
seed.wrapping_add((i as u64).wrapping_mul(0x1000)),
|
||||
wx,
|
||||
@@ -159,4 +199,38 @@ mod tests {
|
||||
(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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,6 +333,15 @@ fn zone_to_family(zone: &MorphologyZone) -> MorphologyFamily {
|
||||
/// ## Returns
|
||||
///
|
||||
/// A fully derived `VoxelColumn` with all D-228 axes populated.
|
||||
/// T-1081: the metre span of the voxel-tier mid-scale relief field — the amplitude
|
||||
/// ceiling for the ≈0.25–2 km rolling/ridged hills added to the family base
|
||||
/// elevation. Actual relief at a voxel is `voxel_relief(...) * SPAN`, which the
|
||||
/// envelope (district ruggedness) scales down on gentle terrain, so this is the
|
||||
/// steep-terrain ceiling, not a uniform amplitude. Provisional — tuned against the
|
||||
/// believability probe (T-1079) like the moisture gradient; per-body *absolute*
|
||||
/// elevation span is a later refinement (the T-1080 climate-fields lineage).
|
||||
const VOXEL_RELIEF_SPAN_M: i32 = 300;
|
||||
|
||||
pub fn derive_voxel_column(
|
||||
world_seed: u64,
|
||||
body_id: &str,
|
||||
@@ -427,6 +436,58 @@ pub fn derive_voxel_column(
|
||||
}
|
||||
};
|
||||
|
||||
// ── 3b. Voxel-tier mid-scale relief (T-1081, D-227 / D-243 §2) ────────
|
||||
// The family generators set a coarse district-derived base elevation plus only
|
||||
// ±4 m of per-voxel micro-scatter, leaving the walkable surface near-flat — no
|
||||
// hills or landmarks to navigate by (the T-1081 symptom). Add the mid-scale
|
||||
// (≈0.25–2 km) rolling/ridged relief the district→voxel seam does not otherwise
|
||||
// reach (D-243 §2): detail-scatter enveloped by district ruggedness (slope_q) so
|
||||
// an authored plain stays flat and rugged terrain gains real relief. The relief
|
||||
// field is body-global (SeedDomain::VoxelRelief, position-keyed); the f64
|
||||
// perturbation is truncated to integer metres before assignment (D-010,
|
||||
// truncate-before-decision — the same sanctioned f64 path as the domain warp).
|
||||
//
|
||||
// Skipped for the WaterBody family: open ocean / lake / tidal-flat surfaces stay
|
||||
// at sea level (elevation 0). River channels inside the land families ride up
|
||||
// with their banks — value noise is smooth at 1 m steps, so a channel voxel and
|
||||
// its bank get near-identical relief and the family's relative channel cut holds.
|
||||
// Only the FLAT families take the mid-scale relief. The dramatic families
|
||||
// (CliffCoast / FjordWall / IncisedGorge) already generate strong internal relief
|
||||
// from their own geometry — layering a position-varying field over them would warp
|
||||
// those features (e.g. drown a gorge's wall-to-floor drop under a relief swell).
|
||||
// WaterBody stays at sea level. The flat families are the ones that read flat and
|
||||
// need invented relief; their only feature is a 3–15 m channel, narrow enough that
|
||||
// the smooth relief shifts it bodily with its banks.
|
||||
let takes_mid_scale_relief = matches!(
|
||||
family,
|
||||
MorphologyFamily::AlluvialPlain
|
||||
| MorphologyFamily::LavaField
|
||||
| MorphologyFamily::BraidedDelta
|
||||
| MorphologyFamily::DuneStrand
|
||||
| MorphologyFamily::MeanderReach
|
||||
);
|
||||
if takes_mid_scale_relief {
|
||||
let relief_seed = SeedChain::for_body(world_seed, body_id)
|
||||
.derive(SeedDomain::VoxelRelief, 0)
|
||||
.seed();
|
||||
// Relief envelope (T-1081): the coarse heightmap (~40–78 km/px) carries almost
|
||||
// no slope at this tier (slope_q tops out near 0.1 on a real body), so gating on
|
||||
// slope_q alone would kill the invented relief everywhere. Combine slope with
|
||||
// elevation — high ground reads rugged, coastal lowlands stay gentle — so
|
||||
// detail-scatter raises hills where the body has high terrain and keeps flats
|
||||
// soft. (Tectonic-class ridge sharpening is a later refinement.)
|
||||
let signal = (district_eff.slope_q * 3 + district_eff.elev_q).clamp(0, 100) as f64 / 100.0;
|
||||
let relief = crate::atlas::detail_scatter::voxel_relief(
|
||||
relief_seed,
|
||||
voxel_x as f64,
|
||||
voxel_y as f64,
|
||||
signal,
|
||||
signal,
|
||||
);
|
||||
let relief_m = (relief * VOXEL_RELIEF_SPAN_M as f64) as i32; // truncate (D-010)
|
||||
column.elevation_m = (column.elevation_m + relief_m).max(0);
|
||||
}
|
||||
|
||||
// ── 4. Seasonal cover overlay (D-239 §3, T-1030) ──────────────────────
|
||||
// Applied AFTER family dispatch: the 8 family generators produce the base
|
||||
// axes (terrain/water/vegetation/elevation); cover is a separate orthogonal
|
||||
@@ -2235,6 +2296,86 @@ mod tests {
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
// ── Voxel-tier mid-scale relief (T-1081) ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn voxel_relief_gives_rugged_land_navigable_hills() {
|
||||
// T-1081: a rugged land district must produce mid-scale relief across a ~2 km
|
||||
// span far exceeding the ±4 m micro-scatter (the "flat 0–3 m, no hills" symptom).
|
||||
let mut district = alluvial_district();
|
||||
district.slope_q = 60; // rugged → relief headroom
|
||||
let chunk = alluvial_chunk(&district);
|
||||
let mut elevs = Vec::new();
|
||||
for i in 0..12 {
|
||||
let p = i * 256; // step across the voxel-relief band (0.25–2 km)
|
||||
let col = derive_voxel_column(42, "GJ1c", &district, &chunk, p, p);
|
||||
if col.water == Water::Dry {
|
||||
elevs.push(col.elevation_m);
|
||||
}
|
||||
}
|
||||
assert!(elevs.len() >= 4, "expected mostly dry-land samples");
|
||||
let min = *elevs.iter().min().unwrap();
|
||||
let max = *elevs.iter().max().unwrap();
|
||||
assert!(
|
||||
max - min > 20,
|
||||
"rugged district should have navigable mid-scale relief, got {min}..{max} m"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voxel_relief_respects_low_flat_envelope() {
|
||||
// No slope AND no elevation → no terrain signal → no invented relief (the
|
||||
// envelope rule: don't sprout hills on a flat sea-level plain). Elevation stays
|
||||
// the family base (elev_q/2 = 0 m) plus only the ±4 m micro-scatter / channel cut.
|
||||
let mut district = alluvial_district();
|
||||
district.slope_q = 0;
|
||||
district.elev_q = 0;
|
||||
let chunk = alluvial_chunk(&district);
|
||||
for i in 0..12 {
|
||||
let p = i * 256;
|
||||
let col = derive_voxel_column(42, "GJ1c", &district, &chunk, p, p);
|
||||
assert!(
|
||||
col.elevation_m <= 4,
|
||||
"low-flat district gained mid-scale relief: {} m",
|
||||
col.elevation_m
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voxel_relief_from_elevation_even_with_zero_slope() {
|
||||
// The T-1081 root cause: the coarse heightmap gives slope_q ≈ 0 even on high
|
||||
// terrain, so ELEVATION must drive the relief envelope. A high district with
|
||||
// slope_q == 0 must still gain navigable hills (else high ground reads flat).
|
||||
let mut district = alluvial_district();
|
||||
district.slope_q = 0;
|
||||
district.elev_q = 90; // high ground, coarse-heightmap-flat
|
||||
let chunk = alluvial_chunk(&district);
|
||||
let mut elevs = Vec::new();
|
||||
for i in 0..12 {
|
||||
let p = i * 256;
|
||||
let col = derive_voxel_column(42, "GJ1c", &district, &chunk, p, p);
|
||||
if col.water == Water::Dry {
|
||||
elevs.push(col.elevation_m);
|
||||
}
|
||||
}
|
||||
let range = elevs.iter().max().unwrap() - elevs.iter().min().unwrap();
|
||||
assert!(
|
||||
range > 20,
|
||||
"high terrain must gain relief despite slope_q=0, got range {range} m"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voxel_relief_is_deterministic() {
|
||||
let mut district = alluvial_district();
|
||||
district.slope_q = 50;
|
||||
let chunk = alluvial_chunk(&district);
|
||||
let a = derive_voxel_column(42, "GJ1c", &district, &chunk, 777, 1234);
|
||||
let b = derive_voxel_column(42, "GJ1c", &district, &chunk, 777, 1234);
|
||||
assert_eq!(a.elevation_m, b.elevation_m);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// TerrainMaterial discriminant pins
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -110,6 +110,11 @@ pub enum SeedDomain {
|
||||
/// so the per-cluster cover hash can never collide with the per-voxel terrain
|
||||
/// stream — domain separation guarantees no freeze-to-terrain correlation.
|
||||
Cover = 10,
|
||||
/// Voxel-tier mid-scale relief noise field (T-1081, D-243 §2). A body-global,
|
||||
/// position-keyed elevation perturbation at sub-district wavelengths (≈0.25–2 km),
|
||||
/// keyed by a single constant id (one field per body). Distinct domain so the
|
||||
/// relief lattice can never correlate with the per-voxel terrain or cover streams.
|
||||
VoxelRelief = 11,
|
||||
}
|
||||
|
||||
/// A position in the deterministic seed tree (D-224).
|
||||
|
||||
Reference in New Issue
Block a user