fix(simulation): lake_margin_q normalizes per basin — full-range depth gradient (T-1188)
The PR #206 eyeball caught what every numeric gate passed: the depth signal was visually flat on both test lakes (GJ1c lmq=0 across the whole basin; GJ338Bd 0-13 of 100). Two compounding causes: a fixed absolute ceiling (one body's p90 cell depth) compressing skewed depth distributions into single digits, and heightmap-pitch depth variation being sub-texel-tiny within most basins. lake_margin_q is now depth / the basin's own maximum settled depth: HydrologyResult grows basin_max_depth_scaled (computed in solve() from existing basin_cells membership, broadcast per basin), threaded through HydrologySample.basin_max_depth, normalized in lake_from_hydrology_at with a degenerate-basin epsilon guard (a genuinely uniform pond shades flat — honest, not forced). Lake EXISTENCE (filled > original) is untouched — only tone changes. Measured at district spacing: GJ1c min=0 p50=33 max=84; GJ338Bd min=8 p50=38 max=70 — full-range shore-to-deep ramps on both. No perceptual curve added: the linear per-basin ramp is already well-quartiled. project.yaml 0.4.1 -> 0.4.2 (0.4.1-tagged canvases carrying flat-lmq semantics reached real disk caches during eyeball runs and must miss). Acceptance gates green; zero golden churn (lake_margin_q not captured by either golden shape). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -8,7 +8,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
### Fixed
|
||||
- **Climate now respects latitude on both hemispheres** (T-1186) — a sign-convention bug made every northern hemisphere read as polar (+90°) and the southern hemisphere read as compressed northern tropics: temperate lakes at 76°S, ice only at the top of the world map. Temperature baselines, glaciation, and everything derived from them now band correctly north AND south of the equator — the same spot near the antarctic circle that used to derive as an 18 °C ice-free lake now comes out at 2 °C under light glaciation
|
||||
- **Lakes deepen from the shore** (T-1188) — lake shorelines no longer render as hard flat-blue step-edges: every lake cell now carries its settled-hydrology depth band, and the map shades lakes from pale at the water's edge to dark at the deepest point of the basin — the same visual grammar ocean coastlines already had. (Game version 0.4.0 → 0.4.1: old on-disk map caches refresh automatically)
|
||||
- **Lakes deepen from the shore** (T-1188) — lake shorelines no longer render as hard flat-blue step-edges: every lake cell now carries its settled-hydrology depth band, and the map shades lakes from pale at the water's edge to dark at the deepest point of the basin — the same visual grammar ocean coastlines already had — each lake shaded relative to its own deepest point, so shallow ponds and deep basins both read correctly. (Game version 0.4.0 → 0.4.2: old on-disk map caches refresh automatically)
|
||||
- **The Atlas map now fills the screen properly** (T-1189, T-1192) — the whole-body Global view now fills the frame at an integer texel scale (never blurry, never a sliver in the corner) and centers in the viewport, with the legend sitting beside the map instead of on top of it. The Region zoom step no longer shows the planet repeating side-by-side or smearing into stripes past the poles — the map view is capped at the body's actual size and letterboxed, so what you see is the planet once, correctly framed
|
||||
|
||||
### Added
|
||||
|
||||
+4
-1
@@ -1,7 +1,10 @@
|
||||
name: The Settled Reach
|
||||
# Version scheme: 0.{phase}.{n} — phase = active Development Cascade phase (D-166).
|
||||
# Phase 4 (deterministic world generation) is active.
|
||||
version: 0.4.1
|
||||
# 0.4.1-tagged step canvases carried lake_margin_q's flat absolute-ceiling
|
||||
# semantics (pre-per-basin-normalization) and were written to real disk
|
||||
# caches during T-1188 eyeball runs — 0.4.2 forces those entries to miss.
|
||||
version: 0.4.2
|
||||
repository: settled-reach
|
||||
|
||||
|
||||
|
||||
@@ -229,18 +229,23 @@ pub struct DistrictProfile {
|
||||
/// Settled-hydrology lake-margin depth band (T-1188, D-227 amendment (4)
|
||||
/// continued): `0` at/near the shoreline (the `filled == elevation`
|
||||
/// crossing `lake_from_hydrology_at` gates on), ramping toward `100` as
|
||||
/// the settled water surface sits deeper above the original bedrock —
|
||||
/// quantized `((filled - elevation) / LAKE_MARGIN_DEPTH_CEILING * 100)`,
|
||||
/// clamped. `0` for every non-lake cell (never negative — a cell with no
|
||||
/// settled water above it has no margin to shade). This is the lake
|
||||
/// counterpart to `ocean_fraction_q`'s coastal transition-zone gradient:
|
||||
/// `ocean_fraction_q` is always `0` inside a lake basin (lakes sit ABOVE
|
||||
/// sea level; `ta.ocean_mask` never fires there), so the existing
|
||||
/// TidalFlat/DuneStrand/CliffCoast/Estuarine morphology gates — every one
|
||||
/// keyed on `ocean_fraction_q` — are structurally unreachable at a lake
|
||||
/// edge. `lake_margin_q` gives the client a continuous tone source for
|
||||
/// lake shorelines without inventing a second morphology-classification
|
||||
/// path; see `derive_lake_margin_q`'s doc for the full rationale.
|
||||
/// the settled water surface sits deeper above the original bedrock,
|
||||
/// relative to THIS BASIN's own maximum depth — quantized
|
||||
/// `((filled - elevation) / basin_max_depth * 100)`, clamped, `0` when
|
||||
/// the basin's own max depth is degenerate (see
|
||||
/// `LAKE_MARGIN_DEGENERATE_BASIN_EPSILON`). `0` for every non-lake cell
|
||||
/// (never negative — a cell with no settled water above it has no
|
||||
/// margin to shade). This is the lake counterpart to `ocean_fraction_q`'s
|
||||
/// coastal transition-zone gradient: `ocean_fraction_q` is always `0`
|
||||
/// inside a lake basin (lakes sit ABOVE sea level; `ta.ocean_mask` never
|
||||
/// fires there), so the existing TidalFlat/DuneStrand/CliffCoast/
|
||||
/// Estuarine morphology gates — every one keyed on `ocean_fraction_q` —
|
||||
/// are structurally unreachable at a lake edge. `lake_margin_q` gives
|
||||
/// the client a continuous tone source for lake shorelines without
|
||||
/// inventing a second morphology-classification path; see
|
||||
/// `lake_from_hydrology_at`'s doc for the full per-basin-normalization
|
||||
/// rationale (PR #206 eyeball finding: a fixed absolute ceiling read
|
||||
/// visually flat on real lakes).
|
||||
#[serde(default)]
|
||||
pub lake_margin_q: i32,
|
||||
|
||||
@@ -2101,21 +2106,17 @@ pub(crate) fn bilinear(field: &[f32], w: usize, h: usize, px: f64, py: f64) -> f
|
||||
a + (b - a) * ty
|
||||
}
|
||||
|
||||
/// Saturation ceiling for [`lake_from_hydrology_at`]'s depth-band quantization,
|
||||
/// in the SAME `[0.0, 1.0]` normalized elevation-fraction units as
|
||||
/// `HydrologySample.filled`/`elevation` (NOT metres — no per-body elevation
|
||||
/// span is threaded to this call site, matching `ocean_fraction_q`'s own
|
||||
/// fraction-domain quantization one scope up). T-1188 calibration (GJ338Bd's
|
||||
/// `wiki`-committed lake, `believability-v1` seed): sampled 5,043 flooded
|
||||
/// working-grid cells, depth (`filled - elevation`) p50 ≈ 0.0083, p90 ≈
|
||||
/// 0.058, max ≈ 0.116 — this ceiling sits just above the observed p90 so
|
||||
/// most real basins use the full 0–100 range instead of clipping early
|
||||
/// (a shallow margin near the shore reads near-0, a basin's deep centre
|
||||
/// saturates to 100 — exactly the "deep water reads darker/richer" reading
|
||||
/// the ocean-side `ocean_fraction_q >= 80` OpenOcean floor already assumes).
|
||||
/// A tuning constant, not a measured physical limit — revisit if a
|
||||
/// lore-anchored body ships a dramatically deeper basin.
|
||||
const LAKE_MARGIN_DEPTH_CEILING: f32 = 0.06;
|
||||
/// Degenerate-basin guard for [`lake_from_hydrology_at`]'s per-basin
|
||||
/// normalization: a basin whose own max depth is at or below this floor
|
||||
/// (in the same `[0.0, 1.0]` normalized elevation-fraction units as
|
||||
/// `HydrologySample`) is treated as uniformly shallow — `lake_margin_q`
|
||||
/// reads `0` everywhere in it rather than dividing by a near-zero
|
||||
/// denominator (which would amplify heightmap sampling noise into an
|
||||
/// artificial, meaningless gradient). `1e-5` is ~17× smaller than the
|
||||
/// smallest genuinely-flooded per-cell depth observed in the T-1188
|
||||
/// calibration survey (below), well inside "this basin has no real depth
|
||||
/// signal at this heightmap resolution" territory.
|
||||
const LAKE_MARGIN_DEGENERATE_BASIN_EPSILON: f32 = 1e-5;
|
||||
|
||||
/// The T-1184 settled-hydrology lake test (D-227 amendment (4) / D-255(f)
|
||||
/// mechanism B) PLUS its T-1188 depth-band extension. Returns
|
||||
@@ -2132,9 +2133,30 @@ const LAKE_MARGIN_DEPTH_CEILING: f32 = 0.06;
|
||||
/// here as "fall through to the `ocean_fraction_q` heuristic", never as an
|
||||
/// error).
|
||||
/// - `lake_margin_q` — `0` when `!is_lake` (a non-lake cell has no margin to
|
||||
/// shade); otherwise the settled depth `(filled - original)`, quantized
|
||||
/// against [`LAKE_MARGIN_DEPTH_CEILING`] to `[0, 100]`. T-1188: this is
|
||||
/// the continuous tone source lake shorelines were missing —
|
||||
/// shade); otherwise the settled depth `(filled - original)` at this
|
||||
/// position, normalized against THIS BASIN's own maximum depth
|
||||
/// (`HydrologySample.basin_max_depth`, bilinear-sampled at the SAME
|
||||
/// position — see that field's doc), then quantized to `[0, 100]`.
|
||||
///
|
||||
/// **Per-basin, not a fixed absolute ceiling (PR #206 eyeball finding,
|
||||
/// T-1188 round 2):** the original design used one fixed absolute-depth
|
||||
/// ceiling calibrated against a single body's p90 depth. Two compounding
|
||||
/// effects made that read visually flat on real lakes: (1) a linear
|
||||
/// absolute scale compresses the bulk of any MORE-skewed basin's depth
|
||||
/// distribution into single-digit values; (2) heightmap resolution
|
||||
/// (~40–78 km/px) means within-basin absolute-depth variation is often
|
||||
/// sub-texel-tiny (GJ1c's test basin measured a full-basin depth spread
|
||||
/// under 0.0003 normalized units — genuinely below what a fixed ceiling
|
||||
/// calibrated for a DIFFERENT body's deeper lakes could ever resolve).
|
||||
/// Normalizing against each basin's own max depth fixes both: every
|
||||
/// non-degenerate basin uses the full 0–100 range on ITS OWN terms,
|
||||
/// independent of the body's absolute elevation scale or any other
|
||||
/// basin's depth. A basin at or below
|
||||
/// [`LAKE_MARGIN_DEGENERATE_BASIN_EPSILON`] max depth reads `0`
|
||||
/// everywhere (a genuinely uniform shallow pond shades flat — honest,
|
||||
/// not forced) rather than dividing by ~zero.
|
||||
///
|
||||
/// This is the continuous tone source lake shorelines were missing —
|
||||
/// `ocean_fraction_q` is definitionally `0` throughout a lake basin (lakes
|
||||
/// sit above sea level; `ta.ocean_mask` never fires there), so every
|
||||
/// coastal-transition morphology gate (TidalFlat, DuneStrand, CliffCoast,
|
||||
@@ -2147,13 +2169,12 @@ const LAKE_MARGIN_DEPTH_CEILING: f32 = 0.06;
|
||||
/// check). `lake_margin_q` fixes the PRESENTATION gap (hypothesis (b))
|
||||
/// without touching that already-correct positional refinement.
|
||||
///
|
||||
/// Both `elevation` and `filled` are sampled via the SAME `bilinear` helper
|
||||
/// `ocean_fraction_q`'s own `ta.elev_pct`/`ta.ocean_mask` reads already use at
|
||||
/// every derive-core call site (T-1178/T-1154's per-cell rate numbers already
|
||||
/// include equivalent-cost sampling in the measured per-rung budget — no new
|
||||
/// cost category, per the workshop's own pipeline-slot ruling); computing
|
||||
/// both `is_lake` and `lake_margin_q` from the one bilinear pair costs
|
||||
/// nothing beyond the pre-existing sample.
|
||||
/// All three fields (`elevation`, `filled`, `basin_max_depth`) are sampled
|
||||
/// via the SAME `bilinear` helper `ocean_fraction_q`'s own
|
||||
/// `ta.elev_pct`/`ta.ocean_mask` reads already use at every derive-core call
|
||||
/// site (T-1178/T-1154's per-cell rate numbers already include equivalent-
|
||||
/// cost sampling in the measured per-rung budget — one more bilinear sample
|
||||
/// is not a new cost category, per the workshop's own pipeline-slot ruling).
|
||||
fn lake_from_hydrology_at(ta: &TerrainAnalysis, px: f64, py: f64) -> (bool, i32) {
|
||||
let Some(h) = ta.hydrology.as_ref() else {
|
||||
return (false, 0);
|
||||
@@ -2162,9 +2183,13 @@ fn lake_from_hydrology_at(ta: &TerrainAnalysis, px: f64, py: f64) -> (bool, i32)
|
||||
let original = bilinear(&h.elevation, ta.w, ta.h, px, py);
|
||||
let is_lake = filled > original;
|
||||
let lake_margin_q = if is_lake {
|
||||
(((filled - original) / LAKE_MARGIN_DEPTH_CEILING) * 100.0)
|
||||
.round()
|
||||
.clamp(0.0, 100.0) as i32
|
||||
let basin_max_depth = bilinear(&h.basin_max_depth, ta.w, ta.h, px, py);
|
||||
if basin_max_depth <= LAKE_MARGIN_DEGENERATE_BASIN_EPSILON {
|
||||
0
|
||||
} else {
|
||||
let depth = (filled - original).max(0.0);
|
||||
((depth / basin_max_depth) * 100.0).round().clamp(0.0, 100.0) as i32
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
@@ -134,6 +134,14 @@ pub struct HydrologySample {
|
||||
/// water-surface height at every working-grid cell (equal to
|
||||
/// `elevation` wherever no lake exists).
|
||||
pub filled: Vec<f32>,
|
||||
/// `HydrologyResult.basin_max_depth_scaled`, rescaled back to `[0.0,
|
||||
/// 1.0]` fraction units (T-1188): the MAXIMUM settled depth anywhere in
|
||||
/// this cell's basin, broadcast to every cell in that basin, `0.0` for
|
||||
/// non-lake cells. Used to normalize `lake_margin_q` per-basin instead
|
||||
/// of against a single fixed absolute ceiling — see
|
||||
/// `district_profile::lake_from_hydrology_at`'s doc for the full
|
||||
/// rationale (the PR #206 eyeball finding that motivated this field).
|
||||
pub basin_max_depth: Vec<f32>,
|
||||
}
|
||||
|
||||
const WATER_DIST_CAP: u16 = 255;
|
||||
@@ -215,9 +223,15 @@ impl TerrainAnalysis {
|
||||
.iter()
|
||||
.map(|&s| crate::atlas::hydrology_equilibrium::scaled_to_fraction(s))
|
||||
.collect();
|
||||
let basin_max_depth: Vec<f32> = result
|
||||
.basin_max_depth_scaled
|
||||
.iter()
|
||||
.map(|&s| crate::atlas::hydrology_equilibrium::scaled_to_fraction(s))
|
||||
.collect();
|
||||
self.hydrology = Some(HydrologySample {
|
||||
elevation: elevation.to_vec(),
|
||||
filled,
|
||||
basin_max_depth,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
@@ -176,6 +176,29 @@ pub struct HydrologyResult {
|
||||
/// edge — the rim/floor discontinuity the workshop's red flag 4 asks
|
||||
/// about). Row-major, `w × h`.
|
||||
pub cliff_edge: Vec<bool>,
|
||||
/// T-1188 lake-margin normalization: for every lake cell, the MAXIMUM
|
||||
/// settled depth (`filled_scaled[c] - original_scaled[c]`, i64-scaled)
|
||||
/// anywhere in that cell's basin — broadcast to every cell in the basin
|
||||
/// (not just the deepest one), `0` for non-lake cells. Row-major, `w ×
|
||||
/// h`, computed once here (basin membership — `Basin.cells` — and both
|
||||
/// elevation fields are already in scope at the end of `solve()`) rather
|
||||
/// than re-derived by every caller.
|
||||
///
|
||||
/// **Why per-basin, not a single body-wide constant:** the PR #206
|
||||
/// eyeball found `district_profile`'s original absolute-depth
|
||||
/// `LAKE_MARGIN_DEPTH_CEILING` (a single fixed fraction calibrated
|
||||
/// against one body's p90) compressed the bulk of any more-skewed
|
||||
/// depth distribution into single-digit `lake_margin_q` values — and,
|
||||
/// separately, that heightmap resolution (~40–78 km/px) means
|
||||
/// within-basin absolute-depth variation is sometimes sub-texel-tiny
|
||||
/// (a real basin can be "shallow" in absolute normalized-elevation
|
||||
/// terms while still having a perfectly good internal shore→deep
|
||||
/// gradient relative to ITS OWN range). Normalizing against each
|
||||
/// basin's own max depth makes every non-degenerate lake use the full
|
||||
/// 0–100 tone range regardless of the body's absolute elevation scale —
|
||||
/// the perceptual point of the ticket (a lake's edge should read
|
||||
/// lighter than its centre, on every lake, not just deep ones).
|
||||
pub basin_max_depth_scaled: Vec<i64>,
|
||||
}
|
||||
|
||||
/// Convert an `i64`-scaled elevation value (as carried on
|
||||
@@ -389,11 +412,28 @@ pub fn solve(
|
||||
})
|
||||
.collect();
|
||||
|
||||
// T-1188: per-basin max depth, broadcast to every cell in that basin.
|
||||
// `basin_cells[b]` is exactly the membership list `Basin.cells` above is
|
||||
// built from — walked directly here rather than through `basins` to
|
||||
// avoid a second clone of every basin's cell list.
|
||||
let mut basin_max_depth_scaled = vec![0i64; n];
|
||||
for b in 0..basin_count {
|
||||
let max_depth = basin_cells[b]
|
||||
.iter()
|
||||
.map(|&c| (filled[c] - original[c]).max(0))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
for &c in &basin_cells[b] {
|
||||
basin_max_depth_scaled[c] = max_depth;
|
||||
}
|
||||
}
|
||||
|
||||
HydrologyResult {
|
||||
basins,
|
||||
filled_scaled: filled,
|
||||
channel_depth_scaled,
|
||||
cliff_edge,
|
||||
basin_max_depth_scaled,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user