fix(simulation): region-anchor channels and linear landforms (T-1040, T-1041)
derive_chunk_context now derives channel_anchor_m and coast_anchor_m from the Region-or-higher seed path (D-239 §10); all five voxel placement sites measure distance to these anchors in continuous world coordinates: - T-1040: compute_channel_state / compute_meander_reach_channel / in_levee_band subtract the region anchor instead of measuring from the world origin axis — active channels now exist in their chunks anywhere on the body, continuous across chunk boundaries. has_active_channel is honest in both directions (band-crossing gate; floor covers the BraidedDelta belt at short wavelengths). - T-1041: fjord/cliff/gorge/delta drop their rem_euclid(64) chunk-frame folds — one valley/coastline/fan per region instead of one per chunk. D-239 §9 chokepoint widths preserved; two i32 wall-rise overflows on region-scale distances fixed by clamping before multiply. Harness: 6 new believability tests (channel presence + gate honesty at chunk (1000,-750), cross-boundary continuity, one-landform-per-region, braid-belt confinement); drainage sweeps strengthened with anchor-band chunks. Voxel golden regenerated deliberately: case A now pins a genuinely in-channel Shallow voxel on the anchor, case C a Deep trough voxel matching its label; case B byte-identical and L0/L1 cascade golden untouched (no upstream leak). Review: approved, three minors addressed (truncation-convention docs, delta gate floor, case C relocation). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,11 +18,20 @@
|
||||
//! `RegionProfile`), not from the chunk-local seed. This is enforced by structure:
|
||||
//! the chunk seed is only used for sub-chunk (<64 m) scatter.
|
||||
//!
|
||||
//! The same rule places the feature axes themselves (T-1040/T-1041): the channel
|
||||
//! centreline and landform axis (`channel_anchor_m`) and the coast-face line
|
||||
//! (`coast_anchor_m`) are **world-metre coordinates derived once per region**
|
||||
//! from the region-scale seed. Voxel generators measure distance to these
|
||||
//! anchors in continuous world coordinates — never from the world origin and
|
||||
//! never folded into the 64 m chunk frame.
|
||||
//!
|
||||
//! ## D-010 compliance
|
||||
//!
|
||||
//! All derivation is integer arithmetic. The only f64 in this module is
|
||||
//! `meander_wavelength_m` (positional physics, not a gate comparison), consistent
|
||||
//! with D-239 §4 ("the warp is position math, not a structural decision").
|
||||
//! `meander_wavelength_m` (positional physics, D-239 §4). Structural decisions
|
||||
//! consume it only via deterministic i32 truncation (the `has_active_channel`
|
||||
//! band reach), mirroring the established voxel.rs truncate-before-decision
|
||||
//! convention — only correctly-rounded f64 +,-,*,/ feed the truncation.
|
||||
//!
|
||||
//! ## SeedDomain extension
|
||||
//!
|
||||
@@ -66,6 +75,32 @@ pub enum BasinDirection {
|
||||
/// `BTreeMap` key — implements `Ord` for D-010 determinism.
|
||||
pub type ChunkPos = (i32, i32);
|
||||
|
||||
/// Chunk edge length in metres.
|
||||
pub const CHUNK_M: i32 = 64;
|
||||
|
||||
/// Region edge length in chunks (region ≈ 1 km = 16 chunks). Must match the
|
||||
/// `>> REGION_CHUNKS_SHIFT` region-index mapping used for the region-scale seed.
|
||||
const REGION_CHUNKS_SHIFT: u32 = 4;
|
||||
|
||||
/// Region edge length in metres (1 024 m).
|
||||
const REGION_M: i32 = CHUNK_M << REGION_CHUNKS_SHIFT;
|
||||
|
||||
/// Margin keeping a region's feature anchor away from the region edge, so the
|
||||
/// channel's full swept band (max meander amplitude wavelength/4 ≈ 162 m +
|
||||
/// channel edge + levee band + warp bound ≈ 187 m) stays inside the region.
|
||||
/// Cross-region feature continuity is the stage-2 Voronoi model (T-1040).
|
||||
const ANCHOR_MARGIN_M: i32 = 192;
|
||||
|
||||
/// Seed-addressable anchor span within a region (REGION_M − 2 × margin).
|
||||
const ANCHOR_SPAN_M: i32 = REGION_M - 2 * ANCHOR_MARGIN_M;
|
||||
|
||||
/// Maximum levee band width in metres (`voxel::in_levee_band`: 4 + 3 jitter).
|
||||
const LEVEE_BAND_MAX_M: i32 = 7;
|
||||
|
||||
/// Domain-warp displacement bound in metres (mirrors `domain_warp::WARP_BOUND`,
|
||||
/// D-239 §4 ±8 m). Integer here — used only to widen the channel gate band.
|
||||
const WARP_BOUND_M: i32 = 8;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChunkContext
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -82,11 +117,15 @@ pub type ChunkPos = (i32, i32);
|
||||
/// Used by MeanderReach and AlluvialPlain voxel generators to place the channel.
|
||||
/// - `meander_wavelength_m` — meander wavelength in metres. Derived from
|
||||
/// region-level morphology (slope, moisture), seeded at region scale (> 64 m).
|
||||
/// f64 for positional physics (D-239 §4); not used in any gate comparison.
|
||||
/// - `has_active_channel` — whether a water channel is present in this chunk,
|
||||
/// derived from the region's `ocean_fraction_q` and `river_threshold`.
|
||||
/// f64 for positional physics (D-239 §4); structural decisions consume it
|
||||
/// only via deterministic i32 truncation (the `has_active_channel` band).
|
||||
/// - `has_active_channel` — whether a water channel is present in this chunk:
|
||||
/// the region has water presence AND the channel's swept band around
|
||||
/// `channel_anchor_m` crosses this chunk (T-1040).
|
||||
/// - `channel_width_m` — channel width in metres (integer; D-010). 0 if no
|
||||
/// active channel.
|
||||
/// - `channel_anchor_m` / `coast_anchor_m` — region-anchored feature axes in
|
||||
/// world metres (T-1040/T-1041, D-239 §10).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChunkContext {
|
||||
/// Dominant basin-flow direction for this chunk's drainage catchment.
|
||||
@@ -101,13 +140,28 @@ pub struct ChunkContext {
|
||||
pub meander_wavelength_m: f64,
|
||||
|
||||
/// Whether this chunk contains an active water channel.
|
||||
/// True when the region's `ocean_fraction_q` × `river_threshold` signal
|
||||
/// indicates a perennial waterway crosses this chunk.
|
||||
/// True when the region has water presence (`ocean_fraction_q` ≥ 10) AND
|
||||
/// the channel's swept band around `channel_anchor_m` crosses this chunk's
|
||||
/// cross-axis range (T-1040 — channels exist where the region says, not
|
||||
/// region-wide and not only at the world origin).
|
||||
pub has_active_channel: bool,
|
||||
|
||||
/// Active channel width in metres (integer; D-010). 0 if no active channel.
|
||||
/// Derived from region morphology and slope.
|
||||
pub channel_width_m: i32,
|
||||
|
||||
/// Cross-axis world-metre coordinate of the region's feature centreline:
|
||||
/// channel/meander axis, fjord trough, gorge floor, braid-fan axis.
|
||||
/// Derived once per region from the region-scale seed (T-1040/T-1041,
|
||||
/// D-239 §10) — constant across all chunks of a region, so the feature is
|
||||
/// continuous across chunk boundaries. Cross axis = x for N/S basins,
|
||||
/// y for E/W basins.
|
||||
pub channel_anchor_m: i32,
|
||||
|
||||
/// Along-axis (basin-axis) world-metre coordinate of the CliffCoast face
|
||||
/// line. Region-scale (T-1041): one continuous coast per region, not a
|
||||
/// 64 m sawtooth. Along axis = y for N/S basins, x for E/W basins.
|
||||
pub coast_anchor_m: i32,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -155,21 +209,44 @@ pub fn derive_chunk_context(
|
||||
// is positional physics (D-239 §4, not a gate comparison).
|
||||
let meander_wavelength_m = derive_meander_wavelength(region);
|
||||
|
||||
// Active channel — present when the region has meaningful water presence
|
||||
// (ocean_fraction_q >= 10 indicates a perennial waterway or water body
|
||||
// covers at least 10% of the region). The river_threshold governs D8
|
||||
// drainage accumulation at a finer scale; at the chunk level we use
|
||||
// ocean_fraction_q as the direct proxy for water presence (D-239 §10:
|
||||
// the chunk carries one basin-direction, not a per-tile flow grid).
|
||||
let has_active_channel = region.ocean_fraction_q >= 10;
|
||||
|
||||
// Channel width — derived from ocean_fraction_q (proxy for water presence
|
||||
// at region scale); integer metres; 0 when no active channel.
|
||||
let channel_width_m = if has_active_channel {
|
||||
derive_channel_width(region)
|
||||
} else {
|
||||
0
|
||||
// Region-anchored feature axes (T-1040/T-1041, D-239 §10): channel and
|
||||
// landform centrelines have wavelength > 64 m, so their position derives
|
||||
// from the region-scale seed — never the chunk frame or the world origin.
|
||||
// Cross axis ⊥ basin_direction (x for N/S, y for E/W); along axis ∥ basin.
|
||||
let (cross_chunk, along_chunk) = match basin_direction {
|
||||
BasinDirection::North | BasinDirection::South => (chunk_pos.0, chunk_pos.1),
|
||||
BasinDirection::East | BasinDirection::West => (chunk_pos.1, chunk_pos.0),
|
||||
};
|
||||
let channel_anchor_m = derive_region_anchor(cross_chunk, (region_seed.seed() >> 16) & 0xFFFF);
|
||||
let coast_anchor_m = derive_region_anchor(along_chunk, (region_seed.seed() >> 32) & 0xFFFF);
|
||||
|
||||
// Active channel — water presence (ocean_fraction_q >= 10 indicates a
|
||||
// perennial waterway or water body covers at least 10% of the region) AND
|
||||
// the channel's swept band around the region anchor crosses this chunk
|
||||
// (T-1040). The river_threshold governs D8 drainage accumulation at a finer
|
||||
// scale; at the chunk level ocean_fraction_q is the direct proxy for water
|
||||
// presence (D-239 §10: one basin-direction, not a per-tile flow grid).
|
||||
//
|
||||
// The band is generous (it must cover every chunk that can contain channel,
|
||||
// levee, or warped-channel voxels — a gate-off chunk renders dry), using
|
||||
// the larger MeanderReach amplitude (wavelength/4) for both channel families.
|
||||
let channel_width = derive_channel_width(region);
|
||||
let has_active_channel = region.ocean_fraction_q >= 10 && {
|
||||
let wavelength_i = (meander_wavelength_m as i32).max(10);
|
||||
let amplitude_max = (wavelength_i / 4).max(3);
|
||||
let edge_max = (channel_width / 2).max(2) + 3; // half-width + max edge jitter
|
||||
// Floor: the BraidedDelta belt reaches anchor ±(32 thread-centre + 4
|
||||
// thread-half) before warp regardless of wavelength — the band must
|
||||
// cover it even at the short-wavelength extreme (costs ≤4 m of extra
|
||||
// gate generosity for the other families).
|
||||
let reach = (amplitude_max + edge_max + LEVEE_BAND_MAX_M).max(32 + 4) + WARP_BOUND_M;
|
||||
let cross_lo = cross_chunk * CHUNK_M;
|
||||
let cross_hi = cross_lo + CHUNK_M - 1;
|
||||
cross_lo <= channel_anchor_m + reach && cross_hi >= channel_anchor_m - reach
|
||||
};
|
||||
|
||||
// Channel width — integer metres; 0 when no active channel in this chunk.
|
||||
let channel_width_m = if has_active_channel { channel_width } else { 0 };
|
||||
|
||||
ChunkContext {
|
||||
basin_direction,
|
||||
@@ -177,9 +254,26 @@ pub fn derive_chunk_context(
|
||||
meander_wavelength_m,
|
||||
has_active_channel,
|
||||
channel_width_m,
|
||||
channel_anchor_m,
|
||||
coast_anchor_m,
|
||||
}
|
||||
}
|
||||
|
||||
/// World-metre anchor coordinate for a region-scale feature axis on one axis.
|
||||
///
|
||||
/// The anchor sits in `[region_origin + ANCHOR_MARGIN_M, region_origin +
|
||||
/// REGION_M − ANCHOR_MARGIN_M)` so the feature's full swept band stays inside
|
||||
/// its region (no cross-region band spill; region-seam continuity is the
|
||||
/// stage-2 Voronoi model, T-1040). Same value for every chunk of the region:
|
||||
/// the region index is `axis_chunk >> REGION_CHUNKS_SHIFT` (arithmetic shift =
|
||||
/// floor division, correct for negative chunks) and `seed_bits` comes from the
|
||||
/// shared region-scale seed. Integer arithmetic (D-010).
|
||||
fn derive_region_anchor(axis_chunk: i32, seed_bits: u64) -> i32 {
|
||||
let region_idx = axis_chunk >> REGION_CHUNKS_SHIFT;
|
||||
let origin_m = region_idx * REGION_M;
|
||||
origin_m + ANCHOR_MARGIN_M + (seed_bits % ANCHOR_SPAN_M as u64) as i32
|
||||
}
|
||||
|
||||
/// Fold `(x, y)` chunk coordinates into a single u64 id for seed derivation.
|
||||
///
|
||||
/// Mirrors `domain_warp::pos_to_id` — zigzag-encode + Cantor pairing.
|
||||
@@ -294,6 +388,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The chunk of region (0, 0) whose cross-range contains the region's
|
||||
/// channel anchor — guaranteed inside the T-1040 channel band.
|
||||
fn anchor_chunk_pos(world_seed: u64, body_id: &str, region: &RegionProfile) -> ChunkPos {
|
||||
let probe = derive_chunk_context(world_seed, body_id, region, (0, 0));
|
||||
let idx = probe.channel_anchor_m.div_euclid(CHUNK_M);
|
||||
match probe.basin_direction {
|
||||
BasinDirection::North | BasinDirection::South => (idx, 0),
|
||||
BasinDirection::East | BasinDirection::West => (0, idx),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_chunk_context_is_deterministic() {
|
||||
let region = alluvial_region();
|
||||
@@ -321,14 +426,80 @@ mod tests {
|
||||
#[test]
|
||||
fn alluvial_region_has_active_channel() {
|
||||
let region = alluvial_region();
|
||||
let ctx = derive_chunk_context(42, "GJ1c", ®ion, (5, 5));
|
||||
// ocean_fraction_q=15 + temperate precip → should have active channel.
|
||||
// T-1040: the channel is region-anchored — the chunk under the anchor
|
||||
// must claim it (ocean_fraction_q=15 → water present).
|
||||
let pos = anchor_chunk_pos(42, "GJ1c", ®ion);
|
||||
let ctx = derive_chunk_context(42, "GJ1c", ®ion, pos);
|
||||
assert!(
|
||||
ctx.has_active_channel,
|
||||
"alluvial plain with ocean_fraction_q=15 should have active channel"
|
||||
"anchor-covering chunk of a watered region must have active channel"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunk_outside_channel_band_has_no_active_channel() {
|
||||
// T-1040: channels are region-anchored, not region-wide. A chunk whose
|
||||
// cross-range lies outside the channel's swept band (max reach < 192 m
|
||||
// = 3 chunks) must not claim a channel — pre-fix every chunk of a
|
||||
// watered region did, while voxels rendered dry floodplain.
|
||||
let region = alluvial_region();
|
||||
let (anchor_pos, probe) = {
|
||||
let pos = anchor_chunk_pos(42, "GJ1c", ®ion);
|
||||
(pos, derive_chunk_context(42, "GJ1c", ®ion, pos))
|
||||
};
|
||||
// 8 cross-chunks away (512 m) is past any band reach but still inside
|
||||
// region (0, 0) — the anchor margin keeps the anchor chunk in [3, 12].
|
||||
let anchor_idx = anchor_pos.0.max(anchor_pos.1);
|
||||
let far_idx = if anchor_idx < 8 {
|
||||
anchor_idx + 8
|
||||
} else {
|
||||
anchor_idx - 8
|
||||
};
|
||||
let far_pos = match probe.basin_direction {
|
||||
BasinDirection::North | BasinDirection::South => (far_idx, 0),
|
||||
BasinDirection::East | BasinDirection::West => (0, far_idx),
|
||||
};
|
||||
let far_ctx = derive_chunk_context(42, "GJ1c", ®ion, far_pos);
|
||||
assert!(
|
||||
!far_ctx.has_active_channel,
|
||||
"chunk {far_pos:?} outside the channel band must not claim a channel"
|
||||
);
|
||||
assert_eq!(
|
||||
far_ctx.channel_width_m, 0,
|
||||
"no active channel → channel_width_m must be 0"
|
||||
);
|
||||
// Region-scale params stay constant across the region's chunks.
|
||||
assert_eq!(far_ctx.channel_anchor_m, probe.channel_anchor_m);
|
||||
assert_eq!(far_ctx.meander_phase, probe.meander_phase);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchors_constant_within_region_and_inside_it() {
|
||||
// T-1040/T-1041: feature anchors are a region property — identical for
|
||||
// every chunk of the region, and positioned inside the region's extent.
|
||||
let region = alluvial_region();
|
||||
let base = derive_chunk_context(42, "GJ1c", ®ion, (0, 0));
|
||||
for pos in [(1, 0), (0, 1), (15, 15), (7, 12)] {
|
||||
let ctx = derive_chunk_context(42, "GJ1c", ®ion, pos);
|
||||
assert_eq!(
|
||||
ctx.channel_anchor_m, base.channel_anchor_m,
|
||||
"channel anchor must be region-constant (chunk {pos:?})"
|
||||
);
|
||||
assert_eq!(
|
||||
ctx.coast_anchor_m, base.coast_anchor_m,
|
||||
"coast anchor must be region-constant (chunk {pos:?})"
|
||||
);
|
||||
}
|
||||
// Region (0, 0) spans [0, 1024) m on both axes.
|
||||
assert!((0..1024).contains(&base.channel_anchor_m));
|
||||
assert!((0..1024).contains(&base.coast_anchor_m));
|
||||
// A different region derives its anchors inside its own extent.
|
||||
let far = derive_chunk_context(42, "GJ1c", ®ion, (1000, -750));
|
||||
assert!((62 * 1024..63 * 1024).contains(&far.channel_anchor_m) || (-47 * 1024..-46 * 1024).contains(&far.channel_anchor_m),
|
||||
"far region anchor {} must lie inside its region extent (cross axis depends on basin direction)",
|
||||
far.channel_anchor_m);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_region_has_no_active_channel() {
|
||||
let region = RegionProfile {
|
||||
@@ -353,9 +524,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn channel_width_in_game_feel_range() {
|
||||
// D-239 §9: river crossings 3–15 m.
|
||||
// D-239 §9: river crossings 3–15 m. Measured on a chunk that carries
|
||||
// the channel (T-1040 gating zeroes the width elsewhere).
|
||||
let region = alluvial_region();
|
||||
let ctx = derive_chunk_context(42, "GJ1c", ®ion, (5, 5));
|
||||
let pos = anchor_chunk_pos(42, "GJ1c", ®ion);
|
||||
let ctx = derive_chunk_context(42, "GJ1c", ®ion, pos);
|
||||
assert!(
|
||||
(3..=15).contains(&ctx.channel_width_m),
|
||||
"channel_width_m {} out of game-feel range [3, 15]",
|
||||
|
||||
+242
-205
@@ -413,10 +413,11 @@ pub fn derive_voxel_column(
|
||||
///
|
||||
/// ## Meander channel placement
|
||||
///
|
||||
/// The channel is placed by a sine-wave approximation centred on the chunk's
|
||||
/// `basin_direction`. The perpendicular distance from the voxel to the wave
|
||||
/// determines whether the voxel is in-channel. This avoids per-tile flow
|
||||
/// grids while producing a spatially coherent channel (D-239 §10).
|
||||
/// The channel is a sine-wave approximation running along `basin_direction`,
|
||||
/// centred on the region-anchored `channel_anchor_m` (T-1040). The
|
||||
/// perpendicular distance from the voxel to the wave determines whether the
|
||||
/// voxel is in-channel. This avoids per-tile flow grids while producing a
|
||||
/// spatially coherent channel continuous across chunk boundaries (D-239 §10).
|
||||
///
|
||||
/// Channel width is `chunk.channel_width_m` (game-feel range 3–15 m).
|
||||
fn generate_alluvial_plain(
|
||||
@@ -570,8 +571,9 @@ fn generate_lava_field(
|
||||
/// - Valley floor is narrow (chokepoint — D-239 §9) with `Deep` water.
|
||||
/// - Walls rise steeply; rocky, very little vegetation.
|
||||
///
|
||||
/// The fjord inlet occupies the centre of the chunk (along `basin_direction`).
|
||||
/// Cross-section: wall | moraine | Deep water | moraine | wall.
|
||||
/// The fjord trough runs along `basin_direction` at the region-anchored
|
||||
/// centreline `chunk.channel_anchor_m` (T-1041, D-239 §10): ONE valley spans
|
||||
/// the region's chunks. Cross-section: wall | moraine | Deep water | moraine | wall.
|
||||
fn generate_fjord_wall(
|
||||
region: &RegionProfile,
|
||||
chunk: &ChunkContext,
|
||||
@@ -580,8 +582,7 @@ fn generate_fjord_wall(
|
||||
) -> VoxelColumn {
|
||||
// ── Cross-valley coordinate ───────────────────────────────────────────
|
||||
// Fjord runs along basin_direction; cross = perpendicular distance from
|
||||
// the centre-line. We fold the absolute cross-distance to get a positive
|
||||
// distance in [0, 32] (chunk half-width = 32 m).
|
||||
// the centre-line.
|
||||
let (along, cross) = match chunk.basin_direction {
|
||||
crate::atlas::chunk_context::BasinDirection::North
|
||||
| crate::atlas::chunk_context::BasinDirection::South => (voxel_pos.1, voxel_pos.0),
|
||||
@@ -589,12 +590,10 @@ fn generate_fjord_wall(
|
||||
| crate::atlas::chunk_context::BasinDirection::West => (voxel_pos.0, voxel_pos.1),
|
||||
};
|
||||
|
||||
// Cross-channel absolute distance from centreline (fold into positive).
|
||||
// We use modulo 64 to map the absolute coordinate into the chunk frame.
|
||||
let cross_from_centre = {
|
||||
let rel = cross.rem_euclid(64) - 32; // −32..+31
|
||||
rel.abs() // 0..32
|
||||
};
|
||||
// Cross-channel distance from the region-anchored trough centreline
|
||||
// (T-1041): continuous world coordinates — folding into the 64 m chunk
|
||||
// frame repeated a complete fjord in every chunk (D-239 §10 violation).
|
||||
let cross_from_centre = (cross - chunk.channel_anchor_m).abs();
|
||||
|
||||
// ── U-valley geometry ─────────────────────────────────────────────────
|
||||
// The fjord bottom is 4–8 m wide (chokepoint, D-239 §9 "gorge floors 2–8 m").
|
||||
@@ -646,8 +645,11 @@ fn generate_fjord_wall(
|
||||
// Rock→vertical faces (D-239 §8). Elevation rises steeply from
|
||||
// moraine_outer to the wall top.
|
||||
let wall_dist = cross_from_centre - moraine_outer;
|
||||
// Steep linear rise: 6 m per metre of cross-distance (approximates vertical).
|
||||
let wall_rise = wall_dist * 6;
|
||||
// Steep linear rise: 6 m per metre of cross-distance (approximates
|
||||
// vertical), saturating at a 120 m plateau 20 m out — cross distance
|
||||
// is region-scale post-T-1041, so the rise must not grow unbounded to
|
||||
// the region edge (clamp before multiplying: i32 overflow otherwise).
|
||||
let wall_rise = wall_dist.min(20) * 6;
|
||||
// Cirques (grade ≥ 2): occasional hollowed pocket (2–4 m depression) at
|
||||
// upper wall. seed-gated, ~1-in-8 frequency.
|
||||
let cirque_depression = if glacier_grade >= 2 && (sub_chunk_seed >> 6) & 0x7 == 0 {
|
||||
@@ -696,8 +698,10 @@ fn generate_fjord_wall(
|
||||
/// Vertical rock face at the coastal water edge:
|
||||
/// - `Rock` terrain (D-239 §8 Rock→vertical faces law).
|
||||
/// - Sharp elevation drop to `Shallow`/`Deep` water at the base.
|
||||
/// - The coast runs perpendicular to `basin_direction`; inland is high,
|
||||
/// seaward is near-zero.
|
||||
/// - The coast face sits on the region-anchored line `chunk.coast_anchor_m`
|
||||
/// along the seaward axis (T-1041, D-239 §10): ONE continuous coast line
|
||||
/// per region (warp-displaced), not a 64 m sawtooth. Inland of the line is
|
||||
/// high ground; seaward is ocean.
|
||||
/// - Narrow ledge/platform at the cliff base (passable ground, Shallow).
|
||||
/// - Virtually no flat ground (D-239 §8 — Rock→vertical, not terraced).
|
||||
fn generate_cliff_coast(
|
||||
@@ -707,25 +711,21 @@ fn generate_cliff_coast(
|
||||
sub_chunk_seed: u64,
|
||||
) -> VoxelColumn {
|
||||
// ── Coast orientation ─────────────────────────────────────────────────
|
||||
// basin_direction points seaward (water flows to ocean). The seaward axis
|
||||
// is the "along-flow" direction; cross = coast face direction.
|
||||
// Cross-coordinate from the chunk centre in [−32, +31].
|
||||
let seaward_coord = match chunk.basin_direction {
|
||||
crate::atlas::chunk_context::BasinDirection::North => -voxel_pos.1,
|
||||
crate::atlas::chunk_context::BasinDirection::South => voxel_pos.1,
|
||||
crate::atlas::chunk_context::BasinDirection::East => voxel_pos.0,
|
||||
crate::atlas::chunk_context::BasinDirection::West => -voxel_pos.0,
|
||||
// basin_direction points seaward (water flows to ocean). coast_d is the
|
||||
// signed seaward distance from the region-anchored coast line in
|
||||
// continuous world metres (T-1041): negative = inland, positive = seaward.
|
||||
let coast_d = match chunk.basin_direction {
|
||||
crate::atlas::chunk_context::BasinDirection::North => chunk.coast_anchor_m - voxel_pos.1,
|
||||
crate::atlas::chunk_context::BasinDirection::South => voxel_pos.1 - chunk.coast_anchor_m,
|
||||
crate::atlas::chunk_context::BasinDirection::East => voxel_pos.0 - chunk.coast_anchor_m,
|
||||
crate::atlas::chunk_context::BasinDirection::West => chunk.coast_anchor_m - voxel_pos.0,
|
||||
};
|
||||
|
||||
// Normalise into chunk frame [0, 63] with seaward = 63.
|
||||
let seaward_norm = (seaward_coord.rem_euclid(64)).clamp(0, 63);
|
||||
|
||||
// ── Cliff face ────────────────────────────────────────────────────────
|
||||
// The cliff face is at seaward_norm ≈ 48–55 (roughly 3/4 across the chunk).
|
||||
// Inland (0..47): high ground, rocky top.
|
||||
// Face (48..55): steep drop (>6 m per metre — near-vertical, D-239 §8).
|
||||
// Ledge (56..60): rocky beach platform, Shallow.
|
||||
// Ocean (61..63): Deep water.
|
||||
// Inland (coast_d < 0): high ground, rocky top.
|
||||
// Face (0..8): steep drop (>6 m per metre — near-vertical, D-239 §8).
|
||||
// Ledge (8..13): rocky beach platform, Shallow.
|
||||
// Ocean (≥13): Deep water.
|
||||
//
|
||||
// Cliff height derived from region elevation.
|
||||
let cliff_top_elev = (region.elev_q / 2).max(15); // at least 15 m cliff
|
||||
@@ -735,18 +735,18 @@ fn generate_cliff_coast(
|
||||
// Ledge variation noise: bits [3:5] → 0–2 m.
|
||||
let ledge_noise = ((sub_chunk_seed >> 3) & 0x3) as i32;
|
||||
|
||||
let (elevation_m, water) = if seaward_norm < 48 {
|
||||
let (elevation_m, water) = if coast_d < 0 {
|
||||
// Inland high ground — rocky plateau.
|
||||
let inland_noise = ((sub_chunk_seed >> 5) & 0xF) as i32 - 7; // [−7, +8]
|
||||
let elev = (cliff_top_elev + inland_noise).max(cliff_top_elev / 4);
|
||||
(elev, Water::Dry)
|
||||
} else if seaward_norm < 56 {
|
||||
} else if coast_d < 8 {
|
||||
// Cliff face — steep drop. Each metre seaward drops ~cliff_top/8 metres.
|
||||
let face_dist = seaward_norm - 48; // 0–7
|
||||
let face_dist = coast_d; // 0–7
|
||||
let drop = (face_dist * cliff_top_elev) / 8;
|
||||
let elev = (cliff_top_elev - drop + face_noise).max(1);
|
||||
(elev, Water::Dry)
|
||||
} else if seaward_norm < 61 {
|
||||
} else if coast_d < 13 {
|
||||
// Rocky ledge / splash zone.
|
||||
let elev = (1 + ledge_noise).max(0);
|
||||
(elev, Water::Shallow)
|
||||
@@ -755,17 +755,19 @@ fn generate_cliff_coast(
|
||||
(0, Water::Deep)
|
||||
};
|
||||
|
||||
// Vegetation: nearly barren on rock faces; tiny scatter of Scrub on inland top.
|
||||
// Vegetation: nearly barren on rock faces; tiny scatter of Scrub on the
|
||||
// inland top (≥18 m / ≥28 m inland of the face — same offsets as the old
|
||||
// chunk-frame layout, now measured from the region-anchored coast line).
|
||||
let vegetation = match region.vegetation_class {
|
||||
VegetationClass::Forest | VegetationClass::RiparianThicket => {
|
||||
if seaward_norm < 30 && (sub_chunk_seed >> 10) & 0x7 < 3 {
|
||||
if coast_d < -18 && (sub_chunk_seed >> 10) & 0x7 < 3 {
|
||||
Vegetation::Scrub
|
||||
} else {
|
||||
Vegetation::Barren
|
||||
}
|
||||
}
|
||||
VegetationClass::Scrub | VegetationClass::RiparianScrub => {
|
||||
if seaward_norm < 20 && (sub_chunk_seed >> 10) & 0x3 == 0 {
|
||||
if coast_d < -28 && (sub_chunk_seed >> 10) & 0x3 == 0 {
|
||||
Vegetation::Scrub
|
||||
} else {
|
||||
Vegetation::Barren
|
||||
@@ -795,9 +797,12 @@ fn generate_cliff_coast(
|
||||
/// - Multiple shallow distributary channels (NOT single-thread meander).
|
||||
/// - Very low elevation — near sea level (D-239 §8 drainage monotonicity:
|
||||
/// mouths at sea level).
|
||||
/// - Channel determination: 3 independent braided threads, each offset by
|
||||
/// ~1/3 of the chunk width and width 3–8 m. Thread presence is seeded at
|
||||
/// region scale (meander_phase encodes thread offsets).
|
||||
/// - Channel determination: 3 independent braided threads anastomosing
|
||||
/// across a fan belt centred on the region-anchored axis
|
||||
/// `chunk.channel_anchor_m` (T-1041, D-239 §10), each thread 3–8 m wide.
|
||||
/// Thread offsets are seeded at region scale (meander_phase encodes them),
|
||||
/// so the threads run continuously across the region's chunks instead of
|
||||
/// restarting every 64 m.
|
||||
/// - `Shallow` in-channel; `Dry` on gravel bars between threads.
|
||||
fn generate_braided_delta(
|
||||
region: &RegionProfile,
|
||||
@@ -821,11 +826,9 @@ fn generate_braided_delta(
|
||||
| crate::atlas::chunk_context::BasinDirection::West => (voxel_pos.0, voxel_pos.1),
|
||||
};
|
||||
|
||||
// Map cross into chunk frame [0, 63].
|
||||
let cross_norm = cross.rem_euclid(64);
|
||||
|
||||
// Three braided threads with well-separated centres derived from meander_phase.
|
||||
// Thread centres spread across the chunk width (D-239 §8 Gravel→braided).
|
||||
// Three braided threads with well-separated centres derived from meander_phase,
|
||||
// spread across a 64 m fan belt centred on the region-anchored axis
|
||||
// (T-1041 — world cross coordinates, constant for all chunks of the region).
|
||||
// Each centre uses independent bit-mixing (splitmix64-style) so threads are
|
||||
// provably separated regardless of phase value — no correlated bit-slices.
|
||||
let phase_u64 = chunk.meander_phase as u64;
|
||||
@@ -851,21 +854,19 @@ fn generate_braided_delta(
|
||||
h ^ (h >> 31)
|
||||
};
|
||||
let thread_centres: [i32; 3] = [
|
||||
(mix_a % 64) as i32,
|
||||
(mix_b % 64) as i32,
|
||||
(mix_c % 64) as i32,
|
||||
chunk.channel_anchor_m - 32 + (mix_a % 64) as i32,
|
||||
chunk.channel_anchor_m - 32 + (mix_b % 64) as i32,
|
||||
chunk.channel_anchor_m - 32 + (mix_c % 64) as i32,
|
||||
];
|
||||
|
||||
// Thread width: 3–6 m half-width derived from channel_width_m.
|
||||
let thread_half = (chunk.channel_width_m / 2).clamp(2, 4);
|
||||
|
||||
// Check if voxel is in any braided thread.
|
||||
// True modular distance on a [0, 64) ring — handles wrap at both edges.
|
||||
let in_any_thread = thread_centres.iter().any(|¢re| {
|
||||
let d = (cross_norm - centre).rem_euclid(64);
|
||||
let dist = d.min(64 - d);
|
||||
dist <= thread_half
|
||||
});
|
||||
// Check if voxel is in any braided thread — continuous world-coordinate
|
||||
// distance (the old [0, 64) ring fold restarted the threads every chunk).
|
||||
let in_any_thread = thread_centres
|
||||
.iter()
|
||||
.any(|¢re| (cross - centre).abs() <= thread_half);
|
||||
|
||||
let (elevation_m, water) = if in_any_thread {
|
||||
// In a distributary channel — at or just below base elevation.
|
||||
@@ -1036,6 +1037,9 @@ fn generate_dune_strand(
|
||||
/// - Massive elevation differential: walls are 20–60 m above the floor.
|
||||
/// - `Shallow` water in the incised channel at the floor.
|
||||
/// - Cross-section: wall | wall | narrow floor (with channel) | wall | wall.
|
||||
/// - The gorge runs along `basin_direction` at the region-anchored
|
||||
/// centreline `chunk.channel_anchor_m` (T-1041, D-239 §10): ONE gorge
|
||||
/// spans the region's chunks.
|
||||
fn generate_incised_gorge(
|
||||
region: &RegionProfile,
|
||||
chunk: &ChunkContext,
|
||||
@@ -1050,11 +1054,10 @@ fn generate_incised_gorge(
|
||||
| crate::atlas::chunk_context::BasinDirection::West => (voxel_pos.0, voxel_pos.1),
|
||||
};
|
||||
|
||||
// Distance from gorge centre-line in [0, 32].
|
||||
let cross_from_centre = {
|
||||
let rel = cross.rem_euclid(64) - 32; // −32..+31
|
||||
rel.abs()
|
||||
};
|
||||
// Distance from the region-anchored gorge centreline (T-1041): continuous
|
||||
// world coordinates — folding into the 64 m chunk frame carved a gorge in
|
||||
// every chunk (D-239 §10 violation).
|
||||
let cross_from_centre = (cross - chunk.channel_anchor_m).abs();
|
||||
|
||||
// ── Gorge floor width (D-239 §9: 2–8 m) ──────────────────────────────
|
||||
// Floor half-width: 1–4 m (total 2–8 m). Derived from channel_width_m
|
||||
@@ -1083,8 +1086,11 @@ fn generate_incised_gorge(
|
||||
// Elevation rises steeply: each metre of cross-distance from floor edge
|
||||
// adds wall_step metres. We model this as a steep ramp.
|
||||
let wall_dist = cross_from_centre - floor_half;
|
||||
// Steep wall: 8 m rise per metre of wall distance (near-vertical, §8 Rock law).
|
||||
let wall_rise = wall_dist * 8;
|
||||
// Steep wall: 8 m rise per metre of wall distance (near-vertical, §8
|
||||
// Rock law). The rise saturates at the wall_top clamp below; clamp the
|
||||
// region-scale distance (T-1041) before multiplying — i32 overflow at
|
||||
// extreme coordinates otherwise.
|
||||
let wall_rise = wall_dist.min(16) * 8;
|
||||
let elev = (floor_elev + wall_rise + wall_noise).clamp(floor_elev, wall_top);
|
||||
(elev, Water::Dry)
|
||||
};
|
||||
@@ -1580,7 +1586,8 @@ fn compute_meander_reach_channel(
|
||||
amplitude - (2 * amplitude * (angle_mod - half_wl)) / half_wl.max(1)
|
||||
};
|
||||
|
||||
let perp_distance = (cross - meander_displacement).abs();
|
||||
// Region-anchored centreline (T-1040) — same anchor as AlluvialPlain.
|
||||
let perp_distance = (cross - chunk.channel_anchor_m - meander_displacement).abs();
|
||||
|
||||
// Edge jitter from sub-chunk seed (±1 m), same as AlluvialPlain.
|
||||
let edge_jitter = (sub_chunk_seed & 0x3) as i32;
|
||||
@@ -1621,7 +1628,8 @@ fn in_levee_band(voxel_pos: VoxelPos, chunk: &ChunkContext, sub_chunk_seed: u64)
|
||||
amplitude - (2 * amplitude * (angle_mod - half_wl)) / half_wl.max(1)
|
||||
};
|
||||
|
||||
let perp_distance = (cross - meander_displacement).abs();
|
||||
// Region-anchored centreline (T-1040) — same anchor as the channel itself.
|
||||
let perp_distance = (cross - chunk.channel_anchor_m - meander_displacement).abs();
|
||||
|
||||
let edge_jitter = (sub_chunk_seed & 0x3) as i32;
|
||||
let half_width = (chunk.channel_width_m / 2).max(2);
|
||||
@@ -1690,8 +1698,10 @@ fn compute_channel_state(
|
||||
amplitude - (2 * amplitude * (angle_mod - half_wl)) / half_wl
|
||||
};
|
||||
|
||||
// Perpendicular distance from voxel to channel centreline.
|
||||
let perp_distance = (cross - meander_displacement).abs();
|
||||
// Perpendicular distance from voxel to the region-anchored channel
|
||||
// centreline (T-1040): the channel axis is `chunk.channel_anchor_m` on the
|
||||
// cross axis — a region-scale world coordinate, never the world origin.
|
||||
let perp_distance = (cross - chunk.channel_anchor_m - meander_displacement).abs();
|
||||
|
||||
// Sub-chunk noise: ±1 m jitter on the channel edge (wavelength < 64 m).
|
||||
// Use low bits of seed for integer jitter — D-010.
|
||||
@@ -1935,7 +1945,7 @@ pub const DEFAULT_VOXEL_CACHE_CAPACITY: usize = 8192;
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::atlas::chunk_context::derive_chunk_context;
|
||||
use crate::atlas::chunk_context::{derive_chunk_context, BasinDirection};
|
||||
use crate::atlas::region_profile::{
|
||||
GlaciationGrade, PrecipitationClass, TectonicClass, VegetationClass,
|
||||
};
|
||||
@@ -1944,6 +1954,29 @@ mod tests {
|
||||
// Test fixtures
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// The ChunkContext of the region-(0,0) chunk whose cross-range contains
|
||||
/// the channel anchor — guaranteed inside the T-1040 channel band.
|
||||
/// Feature placement is region-anchored, so tests sample around
|
||||
/// `chunk.channel_anchor_m` instead of assuming chunk-frame positions.
|
||||
fn anchor_chunk(seed: u64, body: &str, region: &RegionProfile) -> ChunkContext {
|
||||
let probe = derive_chunk_context(seed, body, region, (0, 0));
|
||||
let idx = probe.channel_anchor_m.div_euclid(64);
|
||||
let pos = match probe.basin_direction {
|
||||
BasinDirection::North | BasinDirection::South => (idx, 0),
|
||||
BasinDirection::East | BasinDirection::West => (0, idx),
|
||||
};
|
||||
derive_chunk_context(seed, body, region, pos)
|
||||
}
|
||||
|
||||
/// Map a (cross, along) coordinate pair to (tile_x, tile_y) for the
|
||||
/// chunk's basin axes — cross ⊥ basin_direction, along ∥ basin_direction.
|
||||
fn cross_along_to_tile(chunk: &ChunkContext, cross: i32, along: i32) -> (i32, i32) {
|
||||
match chunk.basin_direction {
|
||||
BasinDirection::North | BasinDirection::South => (cross, along),
|
||||
BasinDirection::East | BasinDirection::West => (along, cross),
|
||||
}
|
||||
}
|
||||
|
||||
fn alluvial_region() -> RegionProfile {
|
||||
RegionProfile {
|
||||
morphology_zone: MorphologyZone::AlluvialPlain,
|
||||
@@ -2340,33 +2373,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn fjord_wall_centre_has_deep_water() {
|
||||
// The fjord inlet (centreline of the chunk) should have Deep water.
|
||||
// The fjord trough (region-anchored centreline, T-1041) must have Deep
|
||||
// water. Sample a small window around the anchor — the domain warp can
|
||||
// displace any single tile off the 4–8 m floor.
|
||||
let region = fjord_region();
|
||||
// Use a chunk centred around x=0, basin direction North/South.
|
||||
// The fjord centre is at cross = 0 relative to chunk centre.
|
||||
// Cross coordinate = x, so at x=32 (the mid-chunk point in [0,63]).
|
||||
// We sample at tile_x=32 to hit the centre.
|
||||
let chunk = derive_chunk_context(42, "Fjordheim", ®ion, (0, 0));
|
||||
// Guard: this sweep assumes N/S basin (cross axis = tile_x). If the seed
|
||||
// yields E/W the test would sweep the wrong axis and pass vacuously.
|
||||
let anchor = chunk.channel_anchor_m;
|
||||
let any_deep = (anchor - 4..=anchor + 4).any(|c| {
|
||||
let (tx, ty) = cross_along_to_tile(&chunk, c, 50);
|
||||
derive_voxel_column(42, "Fjordheim", ®ion, &chunk, tx, ty).water == Water::Deep
|
||||
});
|
||||
assert!(
|
||||
matches!(
|
||||
chunk.basin_direction,
|
||||
crate::atlas::chunk_context::BasinDirection::North
|
||||
| crate::atlas::chunk_context::BasinDirection::South
|
||||
),
|
||||
"test assumes N/S basin direction; pick a different seed if this fires"
|
||||
);
|
||||
// Find a position near the fjord centreline. We pick x positions that
|
||||
// map to cross_from_centre = 0 (chunk centre). At x=0 in the grid,
|
||||
// rem_euclid(64) - 32 = -32 → abs=32. At x=32, rem_euclid(64)=32, 32-32=0.
|
||||
// So tile_x near 32 (in basin_direction perpendicular axis for North).
|
||||
// For North/South basin direction: cross = voxel_pos.0.
|
||||
let col_centre = derive_voxel_column(42, "Fjordheim", ®ion, &chunk, 32, 50);
|
||||
assert_eq!(
|
||||
col_centre.water,
|
||||
Water::Deep,
|
||||
"FjordWall centreline must have Deep water (the fjord inlet)"
|
||||
any_deep,
|
||||
"FjordWall centreline (anchor {anchor}) must have Deep water (the fjord inlet)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2375,24 +2394,23 @@ mod tests {
|
||||
// Wall elevation must be substantially higher than fjord floor.
|
||||
let region = fjord_region();
|
||||
let chunk = derive_chunk_context(42, "Fjordheim", ®ion, (0, 0));
|
||||
// Guard: this test assumes N/S basin (cross axis = tile_x).
|
||||
let anchor = chunk.channel_anchor_m;
|
||||
// Floor: the lowest tile in the trough window around the anchor.
|
||||
let floor_elev = (anchor - 4..=anchor + 4)
|
||||
.map(|c| {
|
||||
let (tx, ty) = cross_along_to_tile(&chunk, c, 50);
|
||||
derive_voxel_column(42, "Fjordheim", ®ion, &chunk, tx, ty).elevation_m
|
||||
})
|
||||
.min()
|
||||
.unwrap();
|
||||
// Wall: 30 m off the centreline (past the moraine band at ≤14 m).
|
||||
let (wx, wy) = cross_along_to_tile(&chunk, anchor - 30, 50);
|
||||
let wall_col = derive_voxel_column(42, "Fjordheim", ®ion, &chunk, wx, wy);
|
||||
assert!(
|
||||
matches!(
|
||||
chunk.basin_direction,
|
||||
crate::atlas::chunk_context::BasinDirection::North
|
||||
| crate::atlas::chunk_context::BasinDirection::South
|
||||
),
|
||||
"test assumes N/S basin direction; pick a different seed if this fires"
|
||||
);
|
||||
// Floor at centre (x=32 for North/South basin).
|
||||
let floor_col = derive_voxel_column(42, "Fjordheim", ®ion, &chunk, 32, 50);
|
||||
// Wall far from centre (x = 0 or x = 63 = cross_from_centre ≥ 32).
|
||||
let wall_col = derive_voxel_column(42, "Fjordheim", ®ion, &chunk, 0, 50);
|
||||
assert!(
|
||||
wall_col.elevation_m > floor_col.elevation_m,
|
||||
wall_col.elevation_m > floor_elev,
|
||||
"FjordWall walls ({}) must be higher than the floor ({})",
|
||||
wall_col.elevation_m,
|
||||
floor_col.elevation_m
|
||||
floor_elev
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2422,21 +2440,15 @@ mod tests {
|
||||
// D-239 §9: fjord floor (Deep water) must be 2–8 m wide across a cross-section.
|
||||
// With floor_half = glacier_grade.clamp(2,4) and grade ≥ 2 (upstream gate),
|
||||
// total Deep-water width = 2×floor_half + noise ∈ [4, 8] m.
|
||||
// Cross-section spans the region-anchored trough (T-1041).
|
||||
let region = fjord_region(); // glaciation_grade = Moderate (= 2)
|
||||
let chunk = derive_chunk_context(42, "Fjordheim", ®ion, (0, 0));
|
||||
// Guard: sweep assumes N/S basin (cross axis = tile_x).
|
||||
assert!(
|
||||
matches!(
|
||||
chunk.basin_direction,
|
||||
crate::atlas::chunk_context::BasinDirection::North
|
||||
| crate::atlas::chunk_context::BasinDirection::South
|
||||
),
|
||||
"test assumes N/S basin direction; pick a different seed if this fires"
|
||||
);
|
||||
// Count Deep-water tiles across the 64-tile cross-section.
|
||||
let deep_tiles = (0..64i32)
|
||||
.filter(|&x| {
|
||||
derive_voxel_column(42, "Fjordheim", ®ion, &chunk, x, 50).water == Water::Deep
|
||||
let anchor = chunk.channel_anchor_m;
|
||||
// Count Deep-water tiles across a 64-tile cross-section around the anchor.
|
||||
let deep_tiles = (anchor - 32..anchor + 32)
|
||||
.filter(|&c| {
|
||||
let (tx, ty) = cross_along_to_tile(&chunk, c, 50);
|
||||
derive_voxel_column(42, "Fjordheim", ®ion, &chunk, tx, ty).water == Water::Deep
|
||||
})
|
||||
.count();
|
||||
assert!(
|
||||
@@ -2480,15 +2492,17 @@ mod tests {
|
||||
fn cliff_coast_steep_elevation_drop_near_water() {
|
||||
// Verify the CliffCoast structural property: there must be tiles with Deep
|
||||
// water AND there must be a significant elevation range (high ground + ocean).
|
||||
// We don't hard-code which tile_x is "inland" vs "ocean" because basin_direction
|
||||
// and domain warp interact — instead we sample a full transect and verify the
|
||||
// property that: (a) some tiles have Deep water, (b) the max elevation in
|
||||
// the transect exceeds the ocean elevation by > 10 m (the "vertical face" law).
|
||||
// The coast face sits on the region-anchored line `coast_anchor_m` along
|
||||
// the seaward (basin) axis (T-1041) — sample a transect across it.
|
||||
let region = cliff_region();
|
||||
let chunk = derive_chunk_context(42, "Velen", ®ion, (0, 0));
|
||||
// Sample 64 cross-coast positions at a fixed along-coast coordinate.
|
||||
let cols: Vec<VoxelColumn> = (0..64i32)
|
||||
.map(|x| derive_voxel_column(42, "Velen", ®ion, &chunk, x, 50))
|
||||
let coast = chunk.coast_anchor_m;
|
||||
// Sample 64 positions along the seaward axis, crossing the coast line.
|
||||
let cols: Vec<VoxelColumn> = (coast - 32..coast + 32)
|
||||
.map(|a| {
|
||||
let (tx, ty) = cross_along_to_tile(&chunk, 50, a);
|
||||
derive_voxel_column(42, "Velen", ®ion, &chunk, tx, ty)
|
||||
})
|
||||
.collect();
|
||||
let has_deep = cols.iter().any(|c| c.water == Water::Deep);
|
||||
let max_elev = cols.iter().map(|c| c.elevation_m).max().unwrap();
|
||||
@@ -2577,18 +2591,22 @@ mod tests {
|
||||
#[test]
|
||||
fn braided_delta_has_multiple_shallow_channels() {
|
||||
// Gravel→braided (D-239 §8): NOT single-thread. Multiple channels means
|
||||
// a range of positions across the chunk width should contain some Shallow water.
|
||||
// a range of positions across the fan belt should contain some Shallow
|
||||
// water. The belt is centred on the region-anchored fan axis (T-1041).
|
||||
let region = delta_region();
|
||||
let chunk = derive_chunk_context(42, "delta_body", ®ion, (0, 0));
|
||||
// Sample 64 positions across the chunk width (cross-section).
|
||||
let shallow_count = (0..64i32)
|
||||
.filter(|&x| {
|
||||
derive_voxel_column(42, "delta_body", ®ion, &chunk, x, 100).water
|
||||
let chunk = anchor_chunk(42, "delta_body", ®ion);
|
||||
let anchor = chunk.channel_anchor_m;
|
||||
// Sample 80 positions across the fan belt (anchor ± 40 covers the
|
||||
// 64 m belt plus thread width).
|
||||
let shallow_count = (anchor - 40..anchor + 40)
|
||||
.filter(|&c| {
|
||||
let (tx, ty) = cross_along_to_tile(&chunk, c, 100);
|
||||
derive_voxel_column(42, "delta_body", ®ion, &chunk, tx, ty).water
|
||||
== Water::Shallow
|
||||
})
|
||||
.count();
|
||||
// Three braided threads, each 3–8 m wide → expect at least 6 shallow tiles
|
||||
// in a 64-tile transect.
|
||||
// in the belt transect.
|
||||
assert!(
|
||||
shallow_count >= 6,
|
||||
"BraidedDelta must have multiple braided channels; only {shallow_count} shallow in transect"
|
||||
@@ -2602,9 +2620,13 @@ mod tests {
|
||||
// We verify by checking that Shallow water appears in at least two
|
||||
// disjoint groups separated by Dry ground.
|
||||
let region = delta_region();
|
||||
let chunk = derive_chunk_context(42, "delta_body", ®ion, (0, 0));
|
||||
let waters: Vec<Water> = (0..64i32)
|
||||
.map(|x| derive_voxel_column(42, "delta_body", ®ion, &chunk, x, 100).water)
|
||||
let chunk = anchor_chunk(42, "delta_body", ®ion);
|
||||
let anchor = chunk.channel_anchor_m;
|
||||
let waters: Vec<Water> = (anchor - 40..anchor + 40)
|
||||
.map(|c| {
|
||||
let (tx, ty) = cross_along_to_tile(&chunk, c, 100);
|
||||
derive_voxel_column(42, "delta_body", ®ion, &chunk, tx, ty).water
|
||||
})
|
||||
.collect();
|
||||
// Count Dry→Shallow transitions = number of channel entrances.
|
||||
let transitions = waters
|
||||
@@ -2766,25 +2788,15 @@ mod tests {
|
||||
fn incised_gorge_floor_width_in_spec() {
|
||||
// D-239 §9: gorge floors 2–8 m wide. We verify by checking that the
|
||||
// total width of the Shallow water zone (the incised channel on the floor)
|
||||
// across a cross-section is within [2, 8] m.
|
||||
// across a cross-section is within [2, 8] m. Cross-section spans the
|
||||
// region-anchored gorge centreline (T-1041).
|
||||
let region = gorge_region();
|
||||
let chunk = derive_chunk_context(42, "gorge_body", ®ion, (0, 0));
|
||||
// Guard: this sweep assumes N/S basin (cross axis = tile_x). If the seed
|
||||
// yields E/W the sweep would be along the gorge, not across it.
|
||||
assert!(
|
||||
matches!(
|
||||
chunk.basin_direction,
|
||||
crate::atlas::chunk_context::BasinDirection::North
|
||||
| crate::atlas::chunk_context::BasinDirection::South
|
||||
),
|
||||
"test assumes N/S basin direction; pick a different seed if this fires"
|
||||
);
|
||||
// Sample 64 cross-positions at a fixed along-axis position.
|
||||
// For North/South basin direction: cross = tile_x.
|
||||
// The floor should be ≤ 8 tiles (= 8 m) wide.
|
||||
let floor_tiles = (0..64i32)
|
||||
.filter(|&x| {
|
||||
derive_voxel_column(42, "gorge_body", ®ion, &chunk, x, 100).water
|
||||
let anchor = chunk.channel_anchor_m;
|
||||
let floor_tiles = (anchor - 32..anchor + 32)
|
||||
.filter(|&c| {
|
||||
let (tx, ty) = cross_along_to_tile(&chunk, c, 100);
|
||||
derive_voxel_column(42, "gorge_body", ®ion, &chunk, tx, ty).water
|
||||
== Water::Shallow
|
||||
})
|
||||
.count();
|
||||
@@ -2799,15 +2811,24 @@ mod tests {
|
||||
// Walls must be substantially higher than the gorge floor.
|
||||
let region = gorge_region();
|
||||
let chunk = derive_chunk_context(42, "gorge_body", ®ion, (0, 0));
|
||||
// Floor: tile at cross_from_centre = 0, i.e. tile_x = 32.
|
||||
let floor_col = derive_voxel_column(42, "gorge_body", ®ion, &chunk, 32, 100);
|
||||
// Wall: tile at cross_from_centre = 30, i.e. tile_x = 62 or tile_x = 2.
|
||||
let wall_col = derive_voxel_column(42, "gorge_body", ®ion, &chunk, 62, 100);
|
||||
let anchor = chunk.channel_anchor_m;
|
||||
// Floor: the lowest tile in the trough window around the anchor
|
||||
// (the warp can displace any single tile off the 2–8 m floor).
|
||||
let floor_elev = (anchor - 4..=anchor + 4)
|
||||
.map(|c| {
|
||||
let (tx, ty) = cross_along_to_tile(&chunk, c, 100);
|
||||
derive_voxel_column(42, "gorge_body", ®ion, &chunk, tx, ty).elevation_m
|
||||
})
|
||||
.min()
|
||||
.unwrap();
|
||||
// Wall: 30 m off the centreline.
|
||||
let (wx, wy) = cross_along_to_tile(&chunk, anchor + 30, 100);
|
||||
let wall_col = derive_voxel_column(42, "gorge_body", ®ion, &chunk, wx, wy);
|
||||
assert!(
|
||||
wall_col.elevation_m > floor_col.elevation_m + 10,
|
||||
wall_col.elevation_m > floor_elev + 10,
|
||||
"IncisedGorge walls ({}) must be >> gorge floor ({})",
|
||||
wall_col.elevation_m,
|
||||
floor_col.elevation_m
|
||||
floor_elev
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2906,18 +2927,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn meander_reach_has_active_channel() {
|
||||
// MeanderReach must have a channel with Shallow/Deep water somewhere in a transect.
|
||||
// MeanderReach must have a channel with Shallow/Deep water somewhere in
|
||||
// a transect across the region-anchored channel band (T-1040).
|
||||
let region = meander_region();
|
||||
let chunk = derive_chunk_context(42, "meander_body", ®ion, (0, 0));
|
||||
// The chunk has_active_channel = true (ocean_fraction_q=20 >= 10).
|
||||
let chunk = anchor_chunk(42, "meander_body", ®ion);
|
||||
// The anchor-covering chunk has an active channel (ocean_fraction_q=20 >= 10).
|
||||
assert!(
|
||||
chunk.has_active_channel,
|
||||
"MeanderReach test chunk must have active channel"
|
||||
"MeanderReach anchor chunk must have active channel"
|
||||
);
|
||||
let anchor = chunk.channel_anchor_m;
|
||||
// Sample a wide cross-section and find at least one wet tile.
|
||||
let wet_count = (-200..200i32)
|
||||
.filter(|&x| {
|
||||
let col = derive_voxel_column(42, "meander_body", ®ion, &chunk, x, 50);
|
||||
let wet_count = (anchor - 200..anchor + 200)
|
||||
.filter(|&c| {
|
||||
let (tx, ty) = cross_along_to_tile(&chunk, c, 50);
|
||||
let col = derive_voxel_column(42, "meander_body", ®ion, &chunk, tx, ty);
|
||||
col.water != Water::Dry
|
||||
})
|
||||
.count();
|
||||
@@ -2929,31 +2953,34 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn meander_reach_channel_below_levee() {
|
||||
// D-239 §9 ElevationDelta: channel tiles must have lower elevation than
|
||||
// off-channel tiles in the vicinity.
|
||||
// D-239 §9 ElevationDelta: channel tiles must sit below off-channel
|
||||
// tiles in the vicinity. Per-voxel micro-relief is ±4 m, so a single
|
||||
// wet/dry tile pair is noise — compare the channel bottom (min wet)
|
||||
// against the floodplain/levee top (max dry) across the transect.
|
||||
let region = meander_region();
|
||||
let chunk = derive_chunk_context(42, "meander_body", ®ion, (0, 0));
|
||||
// Find a channel tile and a floodplain tile at the same along-axis position.
|
||||
let mut channel_elev: Option<i32> = None;
|
||||
let mut plain_elev: Option<i32> = None;
|
||||
for tx in -200..200i32 {
|
||||
let col = derive_voxel_column(42, "meander_body", ®ion, &chunk, tx, 50);
|
||||
if col.water != Water::Dry && channel_elev.is_none() {
|
||||
channel_elev = Some(col.elevation_m);
|
||||
}
|
||||
if col.water == Water::Dry {
|
||||
plain_elev = Some(col.elevation_m);
|
||||
}
|
||||
if channel_elev.is_some() && plain_elev.is_some() {
|
||||
break;
|
||||
let chunk = anchor_chunk(42, "meander_body", ®ion);
|
||||
let anchor = chunk.channel_anchor_m;
|
||||
let mut min_wet: Option<i32> = None;
|
||||
let mut max_dry: Option<i32> = None;
|
||||
for c in anchor - 200..anchor + 200 {
|
||||
let (tx, ty) = cross_along_to_tile(&chunk, c, 50);
|
||||
let col = derive_voxel_column(42, "meander_body", ®ion, &chunk, tx, ty);
|
||||
if col.water != Water::Dry {
|
||||
min_wet = Some(min_wet.map_or(col.elevation_m, |e| e.min(col.elevation_m)));
|
||||
} else {
|
||||
max_dry = Some(max_dry.map_or(col.elevation_m, |e| e.max(col.elevation_m)));
|
||||
}
|
||||
}
|
||||
if let (Some(ch_elev), Some(pl_elev)) = (channel_elev, plain_elev) {
|
||||
assert!(
|
||||
ch_elev <= pl_elev,
|
||||
"MeanderReach: channel elevation ({ch_elev}) must be <= floodplain ({pl_elev})"
|
||||
);
|
||||
}
|
||||
// Both tile kinds must exist — a transect across the anchor that finds
|
||||
// no channel means the placement regressed (the law must be exercised).
|
||||
let (min_wet, max_dry) = (
|
||||
min_wet.expect("transect across the anchor must contain channel tiles"),
|
||||
max_dry.expect("transect must contain floodplain tiles"),
|
||||
);
|
||||
assert!(
|
||||
min_wet <= max_dry,
|
||||
"MeanderReach: channel bottom ({min_wet}) must be <= floodplain/levee top ({max_dry})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2984,31 +3011,41 @@ mod tests {
|
||||
morphology_zone: MorphologyZone::AlluvialPlain,
|
||||
..shared_region_base
|
||||
};
|
||||
// Same seed + body → same ChunkContext (same wavelength, phase, channel_width).
|
||||
let meander_chunk = derive_chunk_context(42, "sinuosity_body", &meander_reg, (0, 0));
|
||||
let alluvial_chunk = derive_chunk_context(42, "sinuosity_body", &alluvial_reg, (0, 0));
|
||||
// Same seed + body → same ChunkContext (same wavelength, phase, anchor,
|
||||
// channel_width). Use the anchor-covering chunk (T-1040 band gating).
|
||||
let meander_chunk = anchor_chunk(42, "sinuosity_body", &meander_reg);
|
||||
let alluvial_chunk = anchor_chunk(42, "sinuosity_body", &alluvial_reg);
|
||||
// Verify the chunks share the same meander params (confirming the test setup).
|
||||
assert_eq!(
|
||||
meander_chunk.meander_phase, alluvial_chunk.meander_phase,
|
||||
"test setup requires identical chunk params"
|
||||
);
|
||||
assert_eq!(
|
||||
meander_chunk.channel_anchor_m, alluvial_chunk.channel_anchor_m,
|
||||
"test setup requires identical channel anchors"
|
||||
);
|
||||
assert!(
|
||||
meander_chunk.has_active_channel && alluvial_chunk.has_active_channel,
|
||||
"both chunks must have active channels for the test to be meaningful"
|
||||
);
|
||||
// Collect wet-tile x-positions over one full wavelength transect (at y=50).
|
||||
// We scan far enough to capture the full meander swing — amplitude for
|
||||
// MeanderReach is wl/4, for AlluvialPlain wl/6, so over a [−400,+400] sweep
|
||||
// both complete multiple full cycles and their centreline positions diverge.
|
||||
let meander_wet: Vec<i32> = (-400..400i32)
|
||||
.filter(|&x| {
|
||||
derive_voxel_column(42, "sinuosity_body", &meander_reg, &meander_chunk, x, 50).water
|
||||
// Collect wet-tile cross-positions over a transect at along=50, centred
|
||||
// on the shared anchor. We scan far enough to capture the full meander
|
||||
// swing — amplitude for MeanderReach is wl/4, for AlluvialPlain wl/6,
|
||||
// so over a ±400 sweep both complete multiple full cycles and their
|
||||
// centreline positions diverge.
|
||||
let anchor = meander_chunk.channel_anchor_m;
|
||||
let meander_wet: Vec<i32> = (anchor - 400..anchor + 400)
|
||||
.filter(|&c| {
|
||||
let (tx, ty) = cross_along_to_tile(&meander_chunk, c, 50);
|
||||
derive_voxel_column(42, "sinuosity_body", &meander_reg, &meander_chunk, tx, ty)
|
||||
.water
|
||||
!= Water::Dry
|
||||
})
|
||||
.collect();
|
||||
let alluvial_wet: Vec<i32> = (-400..400i32)
|
||||
.filter(|&x| {
|
||||
derive_voxel_column(42, "sinuosity_body", &alluvial_reg, &alluvial_chunk, x, 50)
|
||||
let alluvial_wet: Vec<i32> = (anchor - 400..anchor + 400)
|
||||
.filter(|&c| {
|
||||
let (tx, ty) = cross_along_to_tile(&alluvial_chunk, c, 50);
|
||||
derive_voxel_column(42, "sinuosity_body", &alluvial_reg, &alluvial_chunk, tx, ty)
|
||||
.water
|
||||
!= Water::Dry
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user