|
|
|
@@ -91,7 +91,10 @@ pub fn analyze(elevation: &[f32], width: u32, height: u32, sea_level: f32) -> Dr
|
|
|
|
|
// 4. Flow accumulation.
|
|
|
|
|
let accum = flow_accumulation(&fdir, w, h);
|
|
|
|
|
|
|
|
|
|
// 5. River network.
|
|
|
|
|
// 5. River network. River-class banding (T-1156) anchors on its own
|
|
|
|
|
// river-restricted max internally — see `extract_river_network` — not on
|
|
|
|
|
// the grid-wide max computed below, so no dependency ordering between
|
|
|
|
|
// the two is needed.
|
|
|
|
|
let river_network = extract_river_network(&accum, &fdir, w, h, sea_level, elevation);
|
|
|
|
|
|
|
|
|
|
// 6. Basin labeling.
|
|
|
|
@@ -104,7 +107,10 @@ pub fn analyze(elevation: &[f32], width: u32, height: u32, sea_level: f32) -> Dr
|
|
|
|
|
let drainage_basins = build_basins(&labels, w, h);
|
|
|
|
|
|
|
|
|
|
// Max accumulation for D-209 strength normalization (clamped ≥ 1 so the
|
|
|
|
|
// division is always well-defined, even on a flat/empty world).
|
|
|
|
|
// division is always well-defined, even on a flat/empty world). This is
|
|
|
|
|
// the grid-wide max (includes below-sea-level cells) — distinct from the
|
|
|
|
|
// river-restricted max `extract_river_network` uses for its own T-1156
|
|
|
|
|
// river-class banding.
|
|
|
|
|
let max_accumulation = accum.iter().copied().max().unwrap_or(1).max(1);
|
|
|
|
|
|
|
|
|
|
DrainageResult {
|
|
|
|
@@ -258,6 +264,30 @@ fn extract_river_network(
|
|
|
|
|
.map(|i| ((i / w) as u16, (i % w) as u16))
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
// River-restricted max accumulation — the ceiling for the T-1156 log-band
|
|
|
|
|
// classifier below. Deliberately NOT the grid-wide `max_accumulation`
|
|
|
|
|
// (DrainageResult's D-209 normalization denominator, which includes
|
|
|
|
|
// below-sea-level ocean cells where accumulation typically peaks, just
|
|
|
|
|
// past a river's mouth): anchoring on that grid-wide value would classify
|
|
|
|
|
// a wet, large-ocean body's actual wettest *river* cell short of trunk,
|
|
|
|
|
// producing an entirely riverless District rung (Araminta's per-rung
|
|
|
|
|
// table shows trunk only at District) on exactly the bodies with the
|
|
|
|
|
// most river to show. Anchoring on the max among cells that passed the
|
|
|
|
|
// `is_river` filter guarantees every body with any river cells has its
|
|
|
|
|
// wettest one classified trunk, by construction — see
|
|
|
|
|
// `classify_river_cell`'s doc comment.
|
|
|
|
|
let river_max_accumulation = (0..n)
|
|
|
|
|
.filter(|&i| is_river[i])
|
|
|
|
|
.map(|i| accum[i])
|
|
|
|
|
.max()
|
|
|
|
|
.unwrap_or(RIVER_THRESHOLD + 1); // unused when river_cells is empty
|
|
|
|
|
|
|
|
|
|
// River class per entry of `river_cells`, same order (T-1156 wave 1).
|
|
|
|
|
let river_class: Vec<u8> = (0..n)
|
|
|
|
|
.filter(|&i| is_river[i])
|
|
|
|
|
.map(|i| classify_river_cell(accum[i], river_max_accumulation))
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
// Confluences: river cells with 2+ river neighbors flowing into them.
|
|
|
|
|
let mut inflow_count = vec![0u8; n];
|
|
|
|
|
for r in 0..h {
|
|
|
|
@@ -314,6 +344,77 @@ fn extract_river_network(
|
|
|
|
|
river_cells,
|
|
|
|
|
confluences,
|
|
|
|
|
mouths,
|
|
|
|
|
river_class,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Bin a river cell's flow accumulation into a quantized class (T-1156 wave 1):
|
|
|
|
|
/// 0=stream, 1=tributary, 2=trunk. The client filters the ladder rung's river
|
|
|
|
|
/// draw by this class (Araminta's per-rung table: Region shows trunk only,
|
|
|
|
|
/// District adds tributary, Quarter shows everything) — no new wire field,
|
|
|
|
|
/// this is the sole carrier (Tyre's ruling).
|
|
|
|
|
///
|
|
|
|
|
/// **Binning: log-scaled fraction of the log-range between `RIVER_THRESHOLD`
|
|
|
|
|
/// (the accumulation floor below which a cell isn't a river cell at all) and
|
|
|
|
|
/// `river_max_accumulation` (the highest flow accumulation among this body's
|
|
|
|
|
/// own river cells), split into equal thirds.** Rationale for log rather than
|
|
|
|
|
/// linear: flow accumulation grows combinatorially downstream (each
|
|
|
|
|
/// confluence roughly sums its tributaries), so a linear split over-populates
|
|
|
|
|
/// the trunk band with anything past the halfway point and starves it on
|
|
|
|
|
/// modest bodies. Log-scaling spreads the bands evenly across orders of
|
|
|
|
|
/// magnitude instead, so a river's headwaters (streams), mid-course
|
|
|
|
|
/// tributaries, and lower trunk read as three roughly even bands on both a
|
|
|
|
|
/// wet, many-confluence body and a dry, single-channel one.
|
|
|
|
|
///
|
|
|
|
|
/// **The ceiling must be `river_max_accumulation` (max over cells that pass
|
|
|
|
|
/// the `is_river` filter — `accum > RIVER_THRESHOLD && elevation >=
|
|
|
|
|
/// sea_level`), never `DrainageResult::max_accumulation` (the grid-wide max
|
|
|
|
|
/// used elsewhere for D-209 strength normalization).** Flow accumulation
|
|
|
|
|
/// peaks right at a river's mouth, typically on the ocean-side cell just past
|
|
|
|
|
/// the coastline — a cell that is *never* a river cell by definition
|
|
|
|
|
/// (`is_river` requires `elevation >= sea_level`). Anchoring on the grid-wide
|
|
|
|
|
/// max therefore admits a ceiling no river cell can ever reach: on a wet body
|
|
|
|
|
/// with a large ocean, where accumulation piles up hardest past the
|
|
|
|
|
/// coastline, every actual river cell would land short of trunk and the
|
|
|
|
|
/// District rung (trunk-only per Araminta's table) would render riverless —
|
|
|
|
|
/// exactly backwards, since that is the body with the most river to show.
|
|
|
|
|
/// Anchoring on `river_max_accumulation` instead guarantees, by construction,
|
|
|
|
|
/// that a body's own wettest *river* cell — not its wettest cell overall —
|
|
|
|
|
/// always lands in the trunk band. Every body with any river cells gets a
|
|
|
|
|
/// trunk, scaled to its own wet/dry character, which is what "this body's
|
|
|
|
|
/// main river" should mean, and it holds unconditionally (not merely "if the
|
|
|
|
|
/// wettest water happens to be fluvial").
|
|
|
|
|
///
|
|
|
|
|
/// Using a per-body-relative ceiling at all (rather than an absolute multiple
|
|
|
|
|
/// of `RIVER_THRESHOLD`, e.g. trunk = accum ≥ 800) is itself deliberate: a
|
|
|
|
|
/// body whose single river barely clears the threshold would classify every
|
|
|
|
|
/// cell as `stream` under an absolute scheme, reading as "no real river"
|
|
|
|
|
/// even though it has exactly one.
|
|
|
|
|
///
|
|
|
|
|
/// Determinism (D-010/D-208): pure integer/float arithmetic on
|
|
|
|
|
/// `(accum, river_max_accumulation)`, no RNG, same body+seed → same class
|
|
|
|
|
/// every run. Monotonic by construction: `log` and the linear division into
|
|
|
|
|
/// thirds are both non-decreasing in `accum`, so a strictly higher
|
|
|
|
|
/// accumulation never produces a strictly lower class.
|
|
|
|
|
fn classify_river_cell(accum: i32, river_max_accumulation: i32) -> u8 {
|
|
|
|
|
// Callers only invoke this for cells that passed `is_river` (accum >
|
|
|
|
|
// RIVER_THRESHOLD == 200), and `river_max_accumulation` is the max over
|
|
|
|
|
// that same cell set, so both logs below are well-defined (positive
|
|
|
|
|
// arguments) and `river_max_accumulation > RIVER_THRESHOLD` always holds
|
|
|
|
|
// when there is at least one river cell.
|
|
|
|
|
let floor = (RIVER_THRESHOLD as f64).ln();
|
|
|
|
|
let ceil = (river_max_accumulation as f64)
|
|
|
|
|
.max(RIVER_THRESHOLD as f64 + 1.0)
|
|
|
|
|
.ln();
|
|
|
|
|
let span = (ceil - floor).max(f64::EPSILON);
|
|
|
|
|
let frac = ((accum as f64).ln() - floor) / span;
|
|
|
|
|
let frac = frac.clamp(0.0, 1.0);
|
|
|
|
|
if frac >= 2.0 / 3.0 {
|
|
|
|
|
2 // trunk
|
|
|
|
|
} else if frac >= 1.0 / 3.0 {
|
|
|
|
|
1 // tributary
|
|
|
|
|
} else {
|
|
|
|
|
0 // stream
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -806,4 +907,134 @@ mod tests {
|
|
|
|
|
let n = res.drainage_basins.len();
|
|
|
|
|
assert!((1..=12).contains(&n), "basin count {n} out of range");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------
|
|
|
|
|
// River class (T-1156 wave 1)
|
|
|
|
|
// -----------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn every_river_cell_has_a_class() {
|
|
|
|
|
let elev = slope_grid(512, 256);
|
|
|
|
|
let result = analyze(&elev, 512, 256, 0.3);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
result.river_network.river_cells.len(),
|
|
|
|
|
result.river_network.river_class.len(),
|
|
|
|
|
"river_class must be parallel/aligned with river_cells"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!result.river_network.river_cells.is_empty(),
|
|
|
|
|
"test grid should produce river cells"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn river_class_monotonic_with_accumulation() {
|
|
|
|
|
// A cell with higher accumulation must never have a lower class than
|
|
|
|
|
// a cell with lower accumulation — the core binning contract.
|
|
|
|
|
let elev = slope_grid(512, 256);
|
|
|
|
|
let result = analyze(&elev, 512, 256, 0.3);
|
|
|
|
|
let rn = &result.river_network;
|
|
|
|
|
assert!(!rn.river_cells.is_empty());
|
|
|
|
|
|
|
|
|
|
// Recover each river cell's accumulation and pair it with its class.
|
|
|
|
|
let w = 512usize;
|
|
|
|
|
let mut pairs: Vec<(i32, u8)> = rn
|
|
|
|
|
.river_cells
|
|
|
|
|
.iter()
|
|
|
|
|
.zip(rn.river_class.iter())
|
|
|
|
|
.map(|(&(r, c), &class)| {
|
|
|
|
|
let idx = r as usize * w + c as usize;
|
|
|
|
|
(result.flow_accumulation[idx], class)
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
pairs.sort_by_key(|&(accum, _)| accum);
|
|
|
|
|
|
|
|
|
|
let mut max_class_seen = 0u8;
|
|
|
|
|
for (_, class) in pairs {
|
|
|
|
|
assert!(
|
|
|
|
|
class >= max_class_seen,
|
|
|
|
|
"monotonicity violated: saw class {class} after class {max_class_seen} \
|
|
|
|
|
in ascending-accumulation order"
|
|
|
|
|
);
|
|
|
|
|
max_class_seen = max_class_seen.max(class);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn at_least_one_trunk_cell_when_rivers_exist() {
|
|
|
|
|
let elev = slope_grid(512, 256);
|
|
|
|
|
let result = analyze(&elev, 512, 256, 0.3);
|
|
|
|
|
assert!(!result.river_network.river_cells.is_empty());
|
|
|
|
|
assert!(
|
|
|
|
|
result.river_network.river_class.contains(&2),
|
|
|
|
|
"a body with any rivers must have at least one trunk (class 2) cell — \
|
|
|
|
|
this is the classify_river_cell river_max_accumulation-anchoring guarantee"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn at_least_one_trunk_cell_on_a_real_body_with_a_large_ocean() {
|
|
|
|
|
// Regression for the grid-wide-max anchoring bug: GJ1c is exactly the
|
|
|
|
|
// "wet body with a large ocean" shape where flow accumulation peaks
|
|
|
|
|
// past the coastline (a non-river cell), which starved the trunk band
|
|
|
|
|
// when the ceiling was anchored on the grid-wide max instead of the
|
|
|
|
|
// river-restricted max. Same body + downsample as the cascade golden
|
|
|
|
|
// (tests/golden/cascade_layer1.json) — 93 river cells there, so this
|
|
|
|
|
// is a real, non-synthetic exercise of the guarantee.
|
|
|
|
|
use crate::atlas::heightmap::load_heightmap_png;
|
|
|
|
|
let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
|
|
|
.join("../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png");
|
|
|
|
|
let heightmap =
|
|
|
|
|
load_heightmap_png(&src, "GJ1c", 0.3).expect("decode committed GJ1c heightmap");
|
|
|
|
|
let small = heightmap.downsample(256, 128);
|
|
|
|
|
let result = analyze(&small.data, small.width, small.height, small.sea_level);
|
|
|
|
|
assert!(
|
|
|
|
|
!result.river_network.river_cells.is_empty(),
|
|
|
|
|
"GJ1c should have river cells at this downsample"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
result.river_network.river_class.contains(&2),
|
|
|
|
|
"GJ1c's own wettest river cell must classify as trunk — river-restricted \
|
|
|
|
|
anchoring must not be starved by ocean-cell accumulation past the coastline"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn river_class_deterministic() {
|
|
|
|
|
let elev = slope_grid(64, 32);
|
|
|
|
|
let r1 = analyze(&elev, 64, 32, 0.3);
|
|
|
|
|
let r2 = analyze(&elev, 64, 32, 0.3);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
r1.river_network.river_class, r2.river_network.river_class,
|
|
|
|
|
"river_class must be deterministic (D-010/D-208)"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn classify_river_cell_barely_above_threshold_still_gets_a_trunk() {
|
|
|
|
|
// A body whose single river barely clears RIVER_THRESHOLD must still
|
|
|
|
|
// classify its own maximum as trunk — the whole point of anchoring
|
|
|
|
|
// the log-range ceiling at river_max_accumulation instead of an
|
|
|
|
|
// absolute multiple of RIVER_THRESHOLD.
|
|
|
|
|
let river_max_accumulation = RIVER_THRESHOLD + 5;
|
|
|
|
|
assert_eq!(
|
|
|
|
|
classify_river_cell(river_max_accumulation, river_max_accumulation),
|
|
|
|
|
2,
|
|
|
|
|
"the body's own max river-cell accumulation must always classify as trunk"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn classify_river_cell_spans_all_three_classes_on_wide_range() {
|
|
|
|
|
// Sanity check on the log-binning: a body with a wide dynamic range
|
|
|
|
|
// (headwater trickles up to a major trunk) should exercise all three
|
|
|
|
|
// classes, not collapse to two.
|
|
|
|
|
let river_max_accumulation = 131_000;
|
|
|
|
|
let low = classify_river_cell(RIVER_THRESHOLD + 1, river_max_accumulation);
|
|
|
|
|
let mid = classify_river_cell(5_000, river_max_accumulation);
|
|
|
|
|
let high = classify_river_cell(river_max_accumulation, river_max_accumulation);
|
|
|
|
|
assert_eq!(low, 0, "just above threshold should be a stream");
|
|
|
|
|
assert_eq!(mid, 1, "mid-range accumulation should be a tributary");
|
|
|
|
|
assert_eq!(high, 2, "the body's max river-cell accumulation should be trunk");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|