feat(simulation): intra-class micro-habitat mosaic v1 — vegetation/water texture (T-1084, D-246)

Sub-chunk mosaic that modulates the D-228 axes into a spatially-coherent,
relief/moisture-conditioned mosaic of micro-habitats within a terrain class —
the fix for the aliveness probe's uniform "Wetland 100%".

- seed.rs: SeedDomain::VoxelMosaic = 12 (append-only; pinned-discriminant guard)
- voxel.rs: five new D-228 axis values (Vegetation::{Meadow,Deadfall,Lichen},
  TerrainMaterial::{Hardpan,Scree}); the per-family-gated mosaic pass
- detail_scatter.rs: voxel_mosaic() selection field (~8-64 m band)
- atlas/mosaic.rs: surface-class palette + integer-basis-point weighted selection
  with moisture-affinity conditioning; temperate/native baseline palettes
- data/mosaic_constants.toml: source-canonical palette mirror
- believability.rs: micro_habitat_distinct metric + D-245 intra-class criterion

v1 scope (D-246-faithful): modulates vegetation + water only, in the vegetated
classes (Wetland/Forest/Grassland), so the D-239 §8 lithology law is untouched;
water is gated on the T-1040 has_active_channel hydrology rule. Material variants
(hardpan/scree) and the extreme classes are a §8-aware follow-up; the biome-aware
palette variants (T-1085 vocab) extend the baseline before generation.

Goldens regenerated (believability + derivation). Full server suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 10:42:20 +02:00
co-authored by Claude Opus 4.8
parent cf80c1e1b5
commit 31b553a3b3
11 changed files with 1022 additions and 9 deletions
+37 -2
View File
@@ -112,6 +112,12 @@ pub struct ContrastMetrics {
/// 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,
/// Minimum distinct micro-habitat axis-triples (terrain × vegetation × water) found
/// in any single sampled VEGETATED-land district (T-1084, D-246). The D-245 intra-class
/// variety screen: a vegetated patch must show ≥K distinct micro-features, never 100%
/// one — near 1 on the pre-mosaic uniform world ("Wetland 100%"), ≥3 once the mosaic
/// textures the class. Barren / water patches are excluded (legitimately uniform).
pub micro_habitat_distinct: usize,
}
/// Coherence checks — does the world render as a *caused* place? Pass/fail counts
@@ -181,8 +187,9 @@ pub fn analyze(
.values()
.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
terrain_materials: 0, // filled from the voxel sample below
voxel_relief_m: 0, // filled from the relief transect below
micro_habitat_distinct: 0, // filled from the per-district triple sets below
};
// ── Voxel-derived metrics over a deterministic seeded spread of districts ──
@@ -196,6 +203,9 @@ pub fn analyze(
// 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);
// Intra-class variety (T-1084): the minimum distinct micro-habitat triples over the
// sampled vegetated-land districts — the worst patch, per the D-245 per-patch floor.
let mut min_habitat_distinct = usize::MAX;
let keys: Vec<DistrictPos> = districts.keys().copied().collect();
for idx in sample_indices(world_seed, keys.len(), VOXEL_SAMPLE_DISTRICTS) {
@@ -210,6 +220,7 @@ pub fn analyze(
// Elevation accumulators for the drainage-monotonicity proxy.
let (mut wet_elev_sum, mut wet_n) = (0i64, 0i64);
let (mut dry_elev_sum, mut dry_n) = (0i64, 0i64);
let mut habitat_triples: BTreeSet<String> = BTreeSet::new();
for sx in (0..CHUNK_M as usize).step_by(VOXEL_SAMPLE_STRIDE) {
for sy in (0..CHUNK_M as usize).step_by(VOXEL_SAMPLE_STRIDE) {
@@ -217,6 +228,10 @@ pub fn analyze(
let ty = chunk.1 * CHUNK_M + sy as i32;
let col = derive_voxel_column(world_seed, body_id, prof, &ctx, tx, ty);
terrain_set.insert(format!("{:?}", col.terrain));
habitat_triples.insert(format!(
"{:?}/{:?}/{:?}",
col.terrain, col.vegetation, col.water
));
if col.vegetation != Vegetation::Barren {
any_veg = true;
}
@@ -231,6 +246,12 @@ pub fn analyze(
}
}
// Intra-class micro-habitat variety (T-1084): distinct habitat triples in this
// patch; the report tracks the worst (minimum) vegetated patch.
if any_veg {
min_habitat_distinct = min_habitat_distinct.min(habitat_triples.len());
}
// Water coherence (T-1082): water-present districts must render wet voxels.
if prof.ocean_fraction_q >= WATER_PRESENCE_Q || ctx.has_active_channel {
coh.water_districts += 1;
@@ -294,6 +315,11 @@ pub fn analyze(
} else {
0
};
let micro_habitat_distinct = if min_habitat_distinct == usize::MAX {
0
} else {
min_habitat_distinct
};
BelievabilityReport {
body_id: body_id.to_string(),
@@ -303,6 +329,7 @@ pub fn analyze(
contrast: ContrastMetrics {
terrain_materials: terrain_set.len(),
voxel_relief_m,
micro_habitat_distinct,
..contrast_scalar
},
coherence: coh,
@@ -346,6 +373,14 @@ pub fn evaluate_criteria(r: &BelievabilityReport) -> Vec<Criterion> {
pass: c.terrain_materials >= 2,
detail: format!("{} distinct materials", c.terrain_materials),
},
Criterion {
name: "intra-class variety",
pass: c.micro_habitat_distinct >= 3,
detail: format!(
"min {} distinct micro-habitats per vegetated patch",
c.micro_habitat_distinct
),
},
Criterion {
name: "water renders wet",
pass: pct(h.water_districts_wet, h.water_districts) >= 50,
+78
View File
@@ -32,6 +32,11 @@ const OCTAVE_WAVELENGTHS_M: [f64; 4] = [32_768.0, 16_384.0, 8_192.0, 4_096.0];
/// 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 {
@@ -95,6 +100,35 @@ pub fn voxel_relief(seed: u64, wx: f64, wy: f64, envelope: f64, ruggedness: f64)
)
}
/// 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]`).
@@ -235,4 +269,48 @@ mod tests {
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()
);
}
}
+1
View File
@@ -21,6 +21,7 @@ pub mod gen_queue;
pub mod heightmap;
pub mod layer1;
pub mod layer_proxy;
pub mod mosaic;
pub mod plugin;
pub mod region_profile;
pub mod road_graph;
+295
View File
@@ -0,0 +1,295 @@
//! Intra-class micro-habitat mosaic (D-246, T-1084) — the sub-chunk palette that
//! modulates the D-228 axes into a spatially-coherent, relief/moisture-conditioned
//! mosaic of micro-habitats within a single terrain class.
//!
//! Mechanism (D-246): [`detail_scatter::voxel_mosaic`](crate::atlas::detail_scatter::voxel_mosaic)
//! supplies a smooth, body-global selection field in `[0, 1]`; this module maps a
//! voxel's base surface-class to a weighted palette of micro-habitats and picks one
//! by thresholding that field against cumulative weights — conditioned on a local
//! wetness signal (relief micro-thalweg + district moisture) so wet habitats
//! (creek / bog / marsh) land in the lows and dry ones (meadow / glade / clearing) on
//! the rises. Selection is **integer basis-points** (D-010): the f64 field and
//! wetness are the sanctioned positional inputs, truncated before the integer decision.
//!
//! Micro-habitats are distinct D-228 **axis-triples**, not names — several display
//! names (scree/talus, outcrop/ledge) collapse to one triple, the name being a
//! downstream derived label (D-228).
//!
//! Data residency (D-246): the palette is source-canonical in
//! `server/data/mosaic_constants.toml`; the embedded consts here mirror it and are
//! the authoritative runtime source today — the same embedded-default + TOML-mirror
//! pattern as `climate_constants.toml` (runtime loading lands with production tuning).
//! This is the **temperate / native baseline** register; biome variants
//! (savanna / tundra / boreal) and the introduced-Earth register extend the table via
//! T-1085 before generation.
use crate::atlas::voxel::{TerrainMaterial, Vegetation, Water};
/// How the local wetness signal reweights a micro-habitat.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MoistureAffinity {
/// Unconditioned — flat weight across wet and dry.
Any,
/// Favoured in the wet lows (creek / bog / marsh / oasis / active channel).
Wet,
/// Favoured on the dry rises (meadow / glade / clearing / dune crest / hardpan).
Dry,
}
/// One micro-habitat: a distinct D-228 axis-triple the mosaic can paint, plus its
/// relative weight and moisture affinity. `name` is documentation / a derived-label
/// seed only — it is never stored on the voxel.
#[derive(Debug, Clone, Copy)]
pub struct MicroHabitat {
pub name: &'static str,
pub terrain: TerrainMaterial,
pub vegetation: Vegetation,
pub water: Water,
pub weight: i32,
pub affinity: MoistureAffinity,
}
/// The surface-class palette key (D-246) — derived from the base voxel's terrain +
/// dominant vegetation, never stored.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SurfaceClass {
Wetland,
Forest,
Grassland,
Sand,
Rock,
Gravel,
Lava,
}
impl SurfaceClass {
/// Palette key from the family generator's base output (D-246 Palette key):
/// terrain material selects directly, except `Soil` splits Forest vs Grassland
/// by the district's dominant vegetation. `Hardpan`/`Scree` never arrive as a
/// *base* material (they are mosaic outputs) but map defensively.
pub fn from_base(terrain: TerrainMaterial, dominant_vegetation: Vegetation) -> Self {
match terrain {
TerrainMaterial::Wetland => SurfaceClass::Wetland,
TerrainMaterial::Sand | TerrainMaterial::Hardpan => SurfaceClass::Sand,
TerrainMaterial::Rock | TerrainMaterial::Scree => SurfaceClass::Rock,
TerrainMaterial::Gravel => SurfaceClass::Gravel,
TerrainMaterial::Lava => SurfaceClass::Lava,
TerrainMaterial::Soil => {
if matches!(dominant_vegetation, Vegetation::Forest) {
SurfaceClass::Forest
} else {
SurfaceClass::Grassland
}
}
}
}
}
// Shorthand for the const tables below.
use MoistureAffinity::{Any, Dry, Wet};
use TerrainMaterial as T;
use Vegetation as V;
use Water as W;
const fn h(
name: &'static str,
terrain: TerrainMaterial,
vegetation: Vegetation,
water: Water,
weight: i32,
affinity: MoistureAffinity,
) -> MicroHabitat {
MicroHabitat {
name,
terrain,
vegetation,
water,
weight,
affinity,
}
}
// ── Temperate / native baseline palettes (mirror of mosaic_constants.toml) ──────
const WETLAND: &[MicroHabitat] = &[
h("clearing", T::Wetland, V::Barren, W::Dry, 15, Dry),
h("copse", T::Wetland, V::Forest, W::Dry, 10, Dry),
h("creek", T::Wetland, V::Barren, W::Shallow, 10, Wet),
h("meadow", T::Wetland, V::Meadow, W::Dry, 15, Dry),
h("bog", T::Wetland, V::Scrub, W::Shallow, 12, Wet),
h("marsh", T::Wetland, V::Grass, W::Shallow, 12, Wet),
];
const FOREST: &[MicroHabitat] = &[
h("dense stand", T::Soil, V::Forest, W::Dry, 40, Any),
h("glade", T::Soil, V::Grass, W::Dry, 15, Dry),
h("deadfall", T::Soil, V::Deadfall, W::Dry, 10, Any),
h("brook", T::Soil, V::Thicket, W::Shallow, 10, Wet),
h("fern undergrowth", T::Soil, V::Thicket, W::Dry, 15, Any),
];
const GRASSLAND: &[MicroHabitat] = &[
h("tussock", T::Soil, V::Grass, W::Dry, 40, Any),
h("scrub island", T::Soil, V::Scrub, W::Dry, 18, Dry),
h("wildflower", T::Soil, V::Meadow, W::Dry, 17, Dry),
h("dry wash", T::Gravel, V::Barren, W::Dry, 10, Wet),
];
const SAND: &[MicroHabitat] = &[
h("dune crest", T::Sand, V::Barren, W::Dry, 25, Dry),
h("slack", T::Sand, V::Grass, W::Dry, 15, Wet),
h("hardpan", T::Hardpan, V::Barren, W::Dry, 20, Dry),
h("oasis", T::Sand, V::Forest, W::Shallow, 5, Wet),
h("scrub clump", T::Sand, V::Scrub, W::Dry, 15, Any),
];
const ROCK: &[MicroHabitat] = &[
h("outcrop", T::Rock, V::Barren, W::Dry, 45, Any),
h("scree", T::Scree, V::Barren, W::Dry, 30, Any),
h("lichen", T::Rock, V::Lichen, W::Dry, 25, Any),
];
const GRAVEL: &[MicroHabitat] = &[
h("bar", T::Gravel, V::Barren, W::Dry, 45, Any),
h("active channel", T::Gravel, V::Barren, W::Shallow, 25, Wet),
h("vegetated island", T::Gravel, V::Scrub, W::Dry, 30, Dry),
];
const LAVA: &[MicroHabitat] = &[
h("fresh sheet", T::Lava, V::Barren, W::Dry, 50, Any),
h("weathered crust", T::Lava, V::Lichen, W::Dry, 30, Any),
h("pioneer scrub", T::Lava, V::Scrub, W::Dry, 20, Dry),
];
/// The embedded palette for a surface-class (temperate / native baseline).
pub fn palette(class: SurfaceClass) -> &'static [MicroHabitat] {
match class {
SurfaceClass::Wetland => WETLAND,
SurfaceClass::Forest => FOREST,
SurfaceClass::Grassland => GRASSLAND,
SurfaceClass::Sand => SAND,
SurfaceClass::Rock => ROCK,
SurfaceClass::Gravel => GRAVEL,
SurfaceClass::Lava => LAVA,
}
}
/// Moisture-conditioned effective weight of one entry, in the same integer units as
/// `weight`. `wet_bp` is the local wetness in basis-points (0..10000). Wet/Dry
/// affinities scale within `[0.2×, 1.0×]` (a 0.2 floor so a habitat can still appear
/// off-affinity); `Any` is flat.
fn effective_weight(hab: &MicroHabitat, wet_bp: i32) -> i64 {
let factor_bp: i32 = match hab.affinity {
MoistureAffinity::Any => 10_000,
MoistureAffinity::Wet => 2_000 + 8 * wet_bp / 10, // 2000..10000
MoistureAffinity::Dry => 2_000 + 8 * (10_000 - wet_bp) / 10,
};
(hab.weight as i64 * factor_bp as i64) / 10_000
}
/// Pick a micro-habitat from `palette` using the mosaic selection field and a local
/// wetness signal. Integer basis-points (D-010): `field` and `wetness` are the
/// sanctioned f64 positional inputs, truncated to bp before the integer decision.
///
/// - `field`: the [`voxel_mosaic`](crate::atlas::detail_scatter::voxel_mosaic) scalar in `[0, 1]`.
/// - `wetness`: local wet/dry signal in `[0, 1]` (relief micro-thalweg + moisture).
pub fn select(palette: &[MicroHabitat], field: f64, wetness: f64) -> &MicroHabitat {
debug_assert!(!palette.is_empty(), "mosaic palette must be non-empty");
let wet_bp = (wetness.clamp(0.0, 1.0) * 10_000.0) as i32; // truncate (D-010)
let total: i64 = palette
.iter()
.map(|hab| effective_weight(hab, wet_bp))
.sum::<i64>()
.max(1);
let field_bp = (field.clamp(0.0, 1.0) * 10_000.0) as i64; // truncate (D-010)
let mut threshold = field_bp * total / 10_000;
for hab in palette {
let w = effective_weight(hab, wet_bp);
if threshold < w {
return hab;
}
threshold -= w;
}
// field == 1.0 edge → last bucket.
palette.last().expect("palette non-empty")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_base_maps_soil_by_vegetation() {
assert_eq!(
SurfaceClass::from_base(T::Soil, V::Forest),
SurfaceClass::Forest
);
assert_eq!(
SurfaceClass::from_base(T::Soil, V::Grass),
SurfaceClass::Grassland
);
assert_eq!(
SurfaceClass::from_base(T::Wetland, V::Barren),
SurfaceClass::Wetland
);
assert_eq!(
SurfaceClass::from_base(T::Lava, V::Barren),
SurfaceClass::Lava
);
}
#[test]
fn every_palette_nonempty_and_positively_weighted() {
for class in [
SurfaceClass::Wetland,
SurfaceClass::Forest,
SurfaceClass::Grassland,
SurfaceClass::Sand,
SurfaceClass::Rock,
SurfaceClass::Gravel,
SurfaceClass::Lava,
] {
let p = palette(class);
assert!(!p.is_empty(), "{class:?} palette empty");
assert!(
p.iter().all(|hab| hab.weight > 0),
"{class:?} has a non-positive weight"
);
}
}
#[test]
fn select_is_deterministic_and_spans_the_palette() {
let p = palette(SurfaceClass::Wetland);
assert_eq!(select(p, 0.37, 0.5).name, select(p, 0.37, 0.5).name);
let names: std::collections::BTreeSet<_> = (0..100)
.map(|i| select(p, i as f64 / 100.0, 0.5).name)
.collect();
assert!(
names.len() >= 3,
"selection collapsed to {} habitats",
names.len()
);
}
#[test]
fn wetness_favours_wet_habitats_over_dry_signal() {
let p = palette(SurfaceClass::Wetland);
let wet_count = |wetness: f64| {
(0..100)
.filter(|i| {
matches!(
select(p, *i as f64 / 100.0, wetness).affinity,
MoistureAffinity::Wet
)
})
.count()
};
assert!(
wet_count(0.95) > wet_count(0.05),
"wet conditioning did not favour wet habitats: {} vs {}",
wet_count(0.95),
wet_count(0.05)
);
}
}
+79 -4
View File
@@ -87,6 +87,12 @@ pub enum TerrainMaterial {
Wetland = 4,
/// Lava — sheets/shield slopes + tubes, immature drainage (D-239 §8).
Lava = 5,
/// Hardpan — compacted flat crust (D-246 mosaic). Hard, fast footing; does not
/// slump to an angle of repose like sand.
Hardpan = 6,
/// Scree — loose angular rock debris (D-246 mosaic). Unstable footing; the
/// derived name is "talus" at a cliff foot, "scree" on an open slope.
Scree = 7,
}
/// Built surface over natural ground (D-228).
@@ -128,6 +134,15 @@ pub enum Vegetation {
Crop = 5,
/// Cleared — formerly vegetated, stripped (player/sim action).
Cleared = 6,
/// Meadow — forb-rich flowering herbaceous (D-246 mosaic). Higher cover than
/// Grass; the wetland *meadow* and grassland *wildflower*.
Meadow = 7,
/// Deadfall — dead woody debris (D-246 mosaic). Obstruction + fire fuel, no
/// canopy concealment.
Deadfall = 8,
/// Lichen — crustose/pioneer biological crust on rock/lava (D-246 mosaic).
/// Distinct from Barren; the rock *lichen* and lava *weathered crust*.
Lichen = 9,
}
impl Vegetation {
@@ -464,6 +479,8 @@ pub fn derive_voxel_column(
// 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.
// Captured for the mosaic wetness signal below (stays 0 where no relief is added).
let mut relief_signal = 0.0f64;
let takes_mid_scale_relief = matches!(
family,
MorphologyFamily::AlluvialPlain
@@ -492,6 +509,58 @@ pub fn derive_voxel_column(
);
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);
relief_signal = relief; // ~[-0.5, +0.5]; negative = mid-scale valley (wetter)
}
// ── 3c. Intra-class micro-habitat mosaic (T-1084, D-246) ──────────────
// Within the vegetated / wet land classes (Wetland, Forest, Grassland), modulate
// VEGETATION + WATER into a spatially-coherent mosaic of micro-habitats: a
// body-global selection field (SeedDomain::VoxelMosaic, ≈8–64 m band) picks a
// habitat from the surface-class palette, conditioned on a local wetness signal
// (mid-scale relief thalweg + district moisture) so wet habitats settle in the
// lows. This is the motivating fix for the probe's uniform "Wetland 100%".
//
// v1 scope: terrain MATERIAL is left exactly as the family generator set it, so
// the D-239 §8 lithology law holds untouched. The material-variant micro-habitats
// (hardpan / scree / dry-wash gravel) and the extreme classes (Rock / Sand /
// Gravel / Lava) need shape-aware §8 reconciliation and are a follow-up. A
// climatically-barren district stays barren (D-245 §5 climate-appropriateness).
// Within these classes this supersedes the per-voxel `scatter_vegetation` scatter.
if !matches!(family, MorphologyFamily::WaterBody) {
use crate::atlas::mosaic::{self, SurfaceClass};
let dominant_veg = Vegetation::from_vegetation_class(district_eff.vegetation_class);
let class = SurfaceClass::from_base(column.terrain, dominant_veg);
let apply = match class {
SurfaceClass::Wetland | SurfaceClass::Forest => true,
// Barren grassland (cold / arid) stays barren — no imposed grass.
SurfaceClass::Grassland => !matches!(dominant_veg, Vegetation::Barren),
// Rock / Sand / Gravel / Lava are material-driven — a §8-aware follow-up.
_ => false,
};
if apply {
let mosaic_seed = SeedChain::for_body(world_seed, body_id)
.derive(SeedDomain::VoxelMosaic, 0)
.seed();
let field = crate::atlas::detail_scatter::voxel_mosaic(
mosaic_seed,
voxel_x as f64,
voxel_y as f64,
);
// Wetness in [0,1]: district moisture, raised in the mid-scale lows and
// lowered on the rises (relief_signal negative in valleys).
let moisture = (district_eff.moisture_q as f64 / 100.0).clamp(0.0, 1.0);
let wetness = (moisture - relief_signal).clamp(0.0, 1.0);
let hab = mosaic::select(mosaic::palette(class), field, wetness);
column.vegetation = hab.vegetation;
// Add standing water only where the family left it dry AND the chunk's
// hydrology gate allows it (T-1040: water placement must agree with
// has_active_channel — water never appears in a channel-free chunk). Wet
// habitats (bog / marsh / creek / brook) thus cluster near channels; away
// from them they render in their dry-ground form.
if column.water == Water::Dry && chunk.has_active_channel {
column.water = hab.water;
}
}
}
// ── 4. Seasonal cover overlay (D-239 §3, T-1030) ──────────────────────
@@ -2433,16 +2502,22 @@ mod tests {
fn alluvial_plain_vegetation_from_district_class() {
let district = alluvial_district(); // vegetation_class = Forest
let chunk = alluvial_chunk(&district);
// Multiple samples to account for sub-chunk scatter; all should be
// in the Forest/Scrub/Grass range (D-239 §8: no-skip law — no Barren in Forest zone).
// Multiple samples to account for the intra-class mosaic (T-1084, D-246),
// which textures a Forest district into dense stand / glade / deadfall / brook /
// fern. The binding invariant is the D-239 §8 no-skip law: NEVER Barren in a
// Forest zone — the Forest palette upholds it (no Barren entry).
for tx in [100, 101, 102, 103, 104] {
let col = derive_voxel_column(42, "GJ1c", &district, &chunk, tx, 100);
assert!(
matches!(
col.vegetation,
Vegetation::Forest | Vegetation::Scrub | Vegetation::Grass
Vegetation::Forest
| Vegetation::Scrub
| Vegetation::Grass
| Vegetation::Thicket
| Vegetation::Deadfall
),
"Forest district must produce Forest/Scrub/Grass vegetation, got {:?}",
"Forest district must stay non-Barren (§8 no-skip), got {:?}",
col.vegetation
);
}
+6
View File
@@ -115,6 +115,11 @@ pub enum SeedDomain {
/// 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,
/// Sub-chunk micro-habitat mosaic selection field (T-1084, D-246). A body-global,
/// position-keyed value-noise field at ≈8–64 m wavelengths (one field per body,
/// keyed by a single constant id). Distinct domain so the mosaic-selection lattice
/// can never correlate with the relief, cover, or per-voxel terrain streams.
VoxelMosaic = 12,
}
/// A position in the deterministic seed tree (D-224).
@@ -275,6 +280,7 @@ mod tests {
assert_eq!(SeedDomain::Voxel as u64, 9);
assert_eq!(SeedDomain::Cover as u64, 10);
assert_eq!(SeedDomain::VoxelRelief as u64, 11);
assert_eq!(SeedDomain::VoxelMosaic as u64, 12);
}
#[test]