feat(simulation): lakes from settled hydrology (D-227, T-1184)
Productionizes the T-1177 equilibrium solver: run_layer1 now solves hydrology once per body (~24ms, mirrors drainage::analyze) and carries it as TerrainAnalysis.hydrology; run_layer1_with_moisture threads the real body moisture ceiling (extracted derive_moisture_ceiling_q), with the T-1177 population-survey default as fallback. The resident rung-0 global tier does not exist yet (T-1181's scope) — hydrology rides TerrainAnalysis and lands in that tier for free when it is built (deviation recorded on the ticket). MorphologyZone::Lake is now sourced from the settled solver at derive time: a gridunit is Lake when bilinear-sampled filled surface exceeds bilinear-sampled original elevation at the sample's own (px, py) — the continuous comparison, so lake edges refine with rung like coastlines; never a discrete basin-cell projection. The gate sits strictly between OpenOcean (>= 80) and the old ocean_fraction heuristic (>= 60), which survives as the derive-fresh fallback when no solve is attached — byte-identical to pre-T-1184 output in that case. Static classification, distinct from the sim-state flooded plane; no endorheic bit (the drains-vs-closed cue is T-1185's outlet-course presence, per the D-227 amendment (4) sequencing). Zero new wire bytes. Acceptance: lake_classification_cache_hit_equals_cache_miss (solve twice independently, byte-identical zones, non-vacuous Lake hit) plus hydrology determinism tests. Golden fidelity: the window golden fixture now builds TerrainAnalysis through the production entry point (run_layer1_with_moisture, per-body), and a dedicated lake_bowl golden body pins the hydrology-sourced Lake path (morphology 1 at ocean_fraction_q 0 — provably not the heuristic); the 108 pre-existing golden rows are byte-identical (pure append). believability.json moved by one lake-shaped line (GJ338Bd voxel_relief_m 27->28, a correctly reclassified lake district leaving the dry-relief sample set). river_course and derivation-harness goldens unchanged. Full suite: 2114 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -78,6 +78,41 @@ pub struct TerrainAnalysis {
|
||||
pub slope_deg: Vec<f32>,
|
||||
/// Elevation percentile [0,1] among land cells (ocean cells = 0.0).
|
||||
pub elev_pct: Vec<f32>,
|
||||
/// Settled-equilibrium hydrology sourcing (T-1184, D-227 amendment (4) /
|
||||
/// D-255(f) seed-chaining mechanism B). `None` when hydrology hasn't been
|
||||
/// solved for this analysis (e.g. every pre-T-1184 call site still using
|
||||
/// bare [`TerrainAnalysis::analyze`], and every unit test that constructs
|
||||
/// a `TerrainAnalysis` directly without going through the hydrology-aware
|
||||
/// entry point) — callers MUST treat `None` as "fall through to the
|
||||
/// `ocean_fraction_q` heuristic", never as an error. `Some` when
|
||||
/// [`TerrainAnalysis::with_hydrology`] populated it from a real
|
||||
/// [`crate::atlas::hydrology_equilibrium::HydrologyResult`].
|
||||
pub hydrology: Option<HydrologySample>,
|
||||
}
|
||||
|
||||
/// The two continuous working-grid fields `derive_morphology_zone`'s lake
|
||||
/// sourcing bilinearly samples (T-1184) — never a discrete basin-membership
|
||||
/// lookup (that gives blocky, non-refining lake edges, the exact D-166
|
||||
/// magnified-composite artifact this design avoids; see D-227 amendment (4)
|
||||
/// / D-255(f) mechanism B). Both fields are row-major, `w × h`, in the SAME
|
||||
/// `[0.0, 1.0]` normalized domain the raw heightmap and `sea_level` already
|
||||
/// share — so a bilinear sample of one is directly comparable to a bilinear
|
||||
/// sample of the other, no rescaling at the call site.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HydrologySample {
|
||||
/// The original (unfilled) heightmap elevation, `[0.0, 1.0]`. Not stored
|
||||
/// anywhere else on `TerrainAnalysis` (`elev_pct` is a land-cell RANK
|
||||
/// percentile, a different quantity — see its own doc) — this is the
|
||||
/// literal `hm.data` the solver's `original` array was built from,
|
||||
/// carried alongside `filled` so both halves of the lake comparison
|
||||
/// sample from the identical grid at the identical resolution.
|
||||
pub elevation: Vec<f32>,
|
||||
/// `HydrologyResult.filled_scaled`, rescaled back from the solver's
|
||||
/// `i64`-scaled integer domain to `[0.0, 1.0]` (dividing by the same
|
||||
/// `ELEV_SCALE` the solver used to go the other way) — the settled
|
||||
/// water-surface height at every working-grid cell (equal to
|
||||
/// `elevation` wherever no lake exists).
|
||||
pub filled: Vec<f32>,
|
||||
}
|
||||
|
||||
const WATER_DIST_CAP: u16 = 255;
|
||||
@@ -115,9 +150,57 @@ impl TerrainAnalysis {
|
||||
water_dist,
|
||||
slope_deg,
|
||||
elev_pct,
|
||||
hydrology: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Populate the settled-hydrology sourcing fields (T-1184, D-227
|
||||
/// amendment (4) / D-255(f) mechanism B) from a solved
|
||||
/// [`crate::atlas::hydrology_equilibrium::HydrologyResult`].
|
||||
///
|
||||
/// Builder-style (consumes and returns `self`) rather than a constructor
|
||||
/// parameter on [`TerrainAnalysis::analyze`] — `analyze` has ~20 call
|
||||
/// sites across production code and tests that have no hydrology input
|
||||
/// (and, per D-227, don't need one: hydrology sourcing is a lake-specific
|
||||
/// refinement, not a precondition for every other terrain field this
|
||||
/// struct carries). Keeping `analyze`'s signature untouched means every
|
||||
/// existing caller keeps working byte-identically; only the two
|
||||
/// production sites that actually solve hydrology
|
||||
/// (`layer1::run_layer1`, `gen_queue::TerrainAnalysisCache::get_or_derive`)
|
||||
/// opt in by chaining this call.
|
||||
///
|
||||
/// Panics if `result`'s grids aren't `self.w * self.h` cells — a
|
||||
/// programmer error (mismatched working-grid resolution between the
|
||||
/// heightmap this `TerrainAnalysis` was built from and the elevation grid
|
||||
/// `solve()` was called on), never a legitimate runtime state.
|
||||
pub fn with_hydrology(
|
||||
mut self,
|
||||
elevation: &[f32],
|
||||
result: &crate::atlas::hydrology_equilibrium::HydrologyResult,
|
||||
) -> TerrainAnalysis {
|
||||
let n = self.w * self.h;
|
||||
assert_eq!(
|
||||
elevation.len(),
|
||||
n,
|
||||
"with_hydrology: elevation grid size does not match TerrainAnalysis dims"
|
||||
);
|
||||
assert_eq!(
|
||||
result.filled_scaled.len(),
|
||||
n,
|
||||
"with_hydrology: HydrologyResult grid size does not match TerrainAnalysis dims"
|
||||
);
|
||||
let filled: Vec<f32> = result
|
||||
.filled_scaled
|
||||
.iter()
|
||||
.map(|&s| crate::atlas::hydrology_equilibrium::scaled_to_fraction(s))
|
||||
.collect();
|
||||
self.hydrology = Some(HydrologySample {
|
||||
elevation: elevation.to_vec(),
|
||||
filled,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_ocean(&self, r: usize, c: usize) -> bool {
|
||||
self.ocean_mask[idx(r, c, self.w)]
|
||||
|
||||
Reference in New Issue
Block a user