feat(simulation): lake_margin_q depth band — lake shorelines gain gradient vocabulary (T-1188)

Lake edges rendered as hard step-edges while ocean coasts got multi-tone
transition bands: every coastal-transition morphology gate keys on
ocean_fraction_q, definitionally 0 inside a lake basin (hypothesis (b)
of the ticket; (a) disproven first — a shoreline-crossing sweep at
2048/512/128m plus a 10m fine sweep all land on the same continuous
crossing, so positional refinement was never broken). New
DistrictProfile.lake_margin_q (0-100 settled-hydrology depth band, from
the same bilinear filled/elevation pair the lake test already samples;
ceiling calibrated just above the observed p90 depth on GJ338Bd's 5,043
flooded cells), threaded through both derive paths onto
EncodedStepCanvas (serde-default for shape tolerance) and down the
client: protocol decode, terrain-layer plane, colorize shades Lake cells
by depth band instead of elev_q (bedrock-under-water, the wrong signal).
project.yaml 0.4.0 -> 0.4.1: the new wire field must invalidate the
client disk cache via its version tag (T-1183's D-192 mechanism).
Acceptance gates green with the new field (lossless round-trip,
cache-hit==cache-miss, every rung).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 13:08:44 +02:00
co-authored by Claude Fable 5
parent 07f0fa307f
commit dec5b0bc43
12 changed files with 218 additions and 33 deletions
+44 -2
View File
@@ -203,7 +203,13 @@ pub enum StepCanvasStatus {
/// than raw dense msgpack, and faster to encode/decode than every
/// alternative measured). `width`/`height` are carried on
/// [`EncodedStepCanvas`] (shared by every field), not duplicated per field.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
///
/// `Default` (T-1188): needed for `#[serde(default)]` on newly-added
/// `EncodedStepCanvas` fields (`lake_margin_q`) so an old-shape payload
/// missing the field still deserializes — an empty `png_bytes` decodes via
/// `png_decode_u8_plane` to an all-zero plane (see that function's
/// empty-input handling), the correct "field absent" reading.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct EncodedField {
pub png_bytes: Vec<u8>,
}
@@ -279,7 +285,8 @@ pub struct CliffSegment {
pub struct EncodedStepCanvas {
pub width: u32,
pub height: u32,
// --- Static geometry plane (6 dense fields) ---
// --- Static geometry plane (7 dense fields — lake_margin_q added T-1188,
// see below; converged shape was 6, this is the one addition since) ---
pub morphology: EncodedField,
pub elev_q: EncodedField,
pub temp_dc: EncodedTempField,
@@ -291,6 +298,25 @@ pub struct EncodedStepCanvas {
/// membership test, a fixed-radius proximity approximation pending real
/// quarter-footprint geometry).
pub settlement_id: EncodedSettlementField,
/// T-1188 — settled-hydrology lake-margin depth band (`0` at/near the
/// shoreline, ramping toward `100` at a basin's deep centre): the
/// continuous tone source lake shorelines were missing, since
/// `ocean_fraction_q` (the field that gives ocean coastlines their
/// multi-tone transition band via `derive_morphology_zone`'s coastal
/// gates) is definitionally `0` throughout a lake basin. Static geometry
/// (a pure function of position, same plane as `elev_q`), NOT sim-state —
/// grouped with the other static fields rather than next to
/// `glaciation`/`flooded_q` below. `#[serde(default)]` (same precedent as
/// `cliffs`) so a MessagePack decode of an old-shape payload doesn't
/// hard-fail on the missing map key; not expected to occur in practice
/// (client/server ship together, D-192, and the client's persistent
/// step-canvas cache is version-tagged — see this ticket's cache-schema
/// note — so a stale disk entry misses rather than decodes), but the
/// derived default (`EncodedField { png_bytes: vec![] }`) is also never
/// itself PNG-decoded on that path — nothing reads `lake_margin_q`
/// before the version tag has already forced a fresh fetch.
#[serde(default)]
pub lake_margin_q: EncodedField,
// --- Sim-state plane (2 dense fields, see struct doc) ---
pub glaciation: EncodedField,
pub flooded_q: EncodedField,
@@ -436,6 +462,7 @@ struct StepCanvasCell {
vegetation: u8,
glaciation: u8,
flooded_q: u8,
lake_margin_q: u8,
}
/// Derive one cell at `(wx, wy)` world metres, dispatching to the D-255(a)/(f)
@@ -485,6 +512,11 @@ fn derive_step_canvas_cell(
// D-253 stub — see EncodedStepCanvas's doc. Always "not flooded"
// until the sim-state driving clock exists.
flooded_q: 0,
// T-1188: the lake-margin depth-band tone source — see
// `district_profile::DistrictProfile::lake_margin_q`'s doc. `0` for
// every non-lake cell, same static-geometry-plane posture as
// `elev_q`/`morphology` (a pure function of position, not sim-state).
lake_margin_q: prof.lake_margin_q.clamp(0, 100) as u8,
}
}
@@ -509,6 +541,10 @@ pub struct RawStepCanvas {
pub settlement_id: Vec<u32>,
pub glaciation: Vec<u8>,
pub flooded_q: Vec<u8>,
/// T-1188 — settled-hydrology lake-margin depth band, the lake-shoreline
/// tone source ocean coastlines already get for free from
/// `ocean_fraction_q`. See `district_profile::DistrictProfile::lake_margin_q`.
pub lake_margin_q: Vec<u8>,
pub courses: Vec<RiverCourse>,
pub cliffs: Vec<CliffSegment>,
}
@@ -976,6 +1012,7 @@ pub fn build_step_canvas(
let mut vegetation = vec![0u8; cells];
let mut glaciation = vec![0u8; cells];
let mut flooded_q = vec![0u8; cells];
let mut lake_margin_q = vec![0u8; cells];
for (row, row_cells) in rows.into_iter().enumerate() {
let base = row * width as usize;
@@ -988,6 +1025,7 @@ pub fn build_step_canvas(
vegetation[i] = cell.vegetation;
glaciation[i] = cell.glaciation;
flooded_q[i] = cell.flooded_q;
lake_margin_q[i] = cell.lake_margin_q;
}
}
@@ -1037,6 +1075,7 @@ pub fn build_step_canvas(
settlement_id,
glaciation,
flooded_q,
lake_margin_q,
courses,
cliffs,
}
@@ -1085,6 +1124,7 @@ pub fn encode_step_canvas(raw: &RawStepCanvas) -> EncodedStepCanvas {
settlement_id: EncodedSettlementField {
values: raw.settlement_id.clone(),
},
lake_margin_q: png_encode_u8_plane(raw.width, raw.height, &raw.lake_margin_q),
glaciation: png_encode_u8_plane(raw.width, raw.height, &raw.glaciation),
flooded_q: png_encode_u8_plane(raw.width, raw.height, &raw.flooded_q),
courses: raw.courses.clone(),
@@ -1107,6 +1147,7 @@ pub fn decode_step_canvas(enc: &EncodedStepCanvas) -> RawStepCanvas {
moisture_q: png_decode_u8_plane(&enc.moisture_q),
vegetation: png_decode_u8_plane(&enc.vegetation),
settlement_id: enc.settlement_id.values.clone(),
lake_margin_q: png_decode_u8_plane(&enc.lake_margin_q),
glaciation: png_decode_u8_plane(&enc.glaciation),
flooded_q: png_decode_u8_plane(&enc.flooded_q),
courses: enc.courses.clone(),
@@ -1850,6 +1891,7 @@ mod tests {
moisture_q: png_encode_u8_plane(1, 1, &[0]),
vegetation: png_encode_u8_plane(1, 1, &[0]),
settlement_id: EncodedSettlementField { values: vec![0] },
lake_margin_q: png_encode_u8_plane(1, 1, &[0]),
glaciation: png_encode_u8_plane(1, 1, &[0]),
flooded_q: png_encode_u8_plane(1, 1, &[0]),
courses: Vec::new(),