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:
@@ -34,10 +34,17 @@ class_name StepCanvasProtocol
|
||||
## (duplicated here per browse_protocol.gd's own "genuinely standalone"
|
||||
## precedent, not shared via a Callable).
|
||||
## EncodedStepCanvas — a map: {width, height, morphology, elev_q, temp_dc,
|
||||
## moisture_q, vegetation, settlement_id, glaciation, flooded_q, courses,
|
||||
## cliffs}. The six PNG-per-field dense planes (morphology/elev_q/
|
||||
## moisture_q/vegetation/glaciation/flooded_q) are each a map
|
||||
## {"png_bytes": [...]} — png_bytes is a Rust `Vec<u8>` with NO
|
||||
## moisture_q, vegetation, settlement_id, lake_margin_q, glaciation,
|
||||
## flooded_q, courses, cliffs}. `lake_margin_q` (T-1188) is a MessagePack
|
||||
## map key that did not exist before this codec version — an older server
|
||||
## build's payload simply omits it (`d.get("lake_margin_q")` below returns
|
||||
## null, decode_png_field() then returns an empty PackedByteArray, the
|
||||
## same "field absent -> draws as the colorize fallback" posture every
|
||||
## other optional plane on this wire already has); a client this new
|
||||
## talking to that old a server is not a supported combination anyway
|
||||
## (D-192 co-ship). The seven PNG-per-field dense planes (morphology/
|
||||
## elev_q/moisture_q/vegetation/lake_margin_q/glaciation/flooded_q) are
|
||||
## each a map {"png_bytes": [...]} — png_bytes is a Rust `Vec<u8>` with NO
|
||||
## serde_bytes annotation anywhere in this codebase (confirmed: grep for
|
||||
## serde_bytes across server/src returns nothing), so serde's blanket
|
||||
## Vec<T> impl serializes it via serialize_seq — a msgpack ARRAY of
|
||||
@@ -173,6 +180,7 @@ static func _decode_encoded_canvas(raw: Variant) -> Variant:
|
||||
if settlement_id_raw is Dictionary
|
||||
else []
|
||||
),
|
||||
"lake_margin_q": decode_png_field(d.get("lake_margin_q")),
|
||||
"glaciation": decode_png_field(d.get("glaciation")),
|
||||
"flooded_q": decode_png_field(d.get("flooded_q")),
|
||||
"courses": d.get("courses", []),
|
||||
|
||||
@@ -177,6 +177,12 @@ const GLACIATION_TINT_ALPHA: Dictionary = {
|
||||
4: 0.70, # IceCap
|
||||
}
|
||||
|
||||
## Lake-margin depth-band lightness endpoints (T-1188) — see
|
||||
## `lake_margin_lightness()`'s own doc, next to
|
||||
## `district_window_elevation_lightness()`, for the full rationale.
|
||||
const LAKE_MARGIN_LIGHTNESS_BASE: float = 0.7
|
||||
const LAKE_MARGIN_LIGHTNESS_RANGE: float = 0.3
|
||||
|
||||
|
||||
static func morphology_color(zone: int) -> Color:
|
||||
if zone >= 0 and zone < MORPHOLOGY_COLORS.size():
|
||||
@@ -289,6 +295,33 @@ static func district_window_elevation_lightness(base: Color, elev_q: int) -> Col
|
||||
return Color(base.r * lightness, base.g * lightness, base.b * lightness, base.a)
|
||||
|
||||
|
||||
## Lake-margin depth-band lightness (T-1188): the lake-shoreline counterpart
|
||||
## to `district_window_elevation_lightness()` above. Ocean coastlines get a
|
||||
## multi-tone transition band for free from `ocean_fraction_q`'s coastal
|
||||
## morphology gates (TidalFlat/DuneStrand/CliffCoast/Estuarine); a lake basin
|
||||
## sits above sea level, so `ocean_fraction_q` is always `0` there and none
|
||||
## of those gates can ever fire — the lake reads as one flat, hard-edged
|
||||
## color. `lake_margin_q` (`district_profile::DistrictProfile.lake_margin_q`,
|
||||
## `0` at/near the shoreline crossing, ramping toward `100` at a basin's deep
|
||||
## centre) is the server-derived depth signal this shades with: multiply RGB
|
||||
## by `LAKE_MARGIN_LIGHTNESS_BASE + LAKE_MARGIN_LIGHTNESS_RANGE *
|
||||
## (lake_margin_q/100)` — INVERTED relative to the elevation ramp (deep water
|
||||
## reads DARKER/richer, not lighter, matching the ocean-side reading that
|
||||
## `MORPHOLOGY_RGB_OPAQUE`'s own OpenOcean entry is already the darkest
|
||||
## water-family hue in the table). Caller-gated (only applied to Lake-zone
|
||||
## cells, matching `district_window_elevation_lightness`'s "caller decides
|
||||
## when" composition discipline — see step_canvas_colorize.gd's `_base_color`).
|
||||
## Endpoint constants declared with the rest of the const block near the top
|
||||
## of this file (gdlint's class-definitions-order rule).
|
||||
static func lake_margin_lightness(base: Color, lake_margin_q: int) -> Color:
|
||||
var clamped: int = clampi(lake_margin_q, 0, 100)
|
||||
var lightness: float = (
|
||||
(LAKE_MARGIN_LIGHTNESS_BASE + LAKE_MARGIN_LIGHTNESS_RANGE)
|
||||
- LAKE_MARGIN_LIGHTNESS_RANGE * (float(clamped) / 100.0)
|
||||
)
|
||||
return Color(base.r * lightness, base.g * lightness, base.b * lightness, base.a)
|
||||
|
||||
|
||||
## Vegetation green-family ramp (D-226 T-1124 amendment §5). Marine (6)
|
||||
## returns Color.TRANSPARENT — "lets the morphology water-blue show through"
|
||||
## per the amendment; the caller must skip the draw_rect entirely on a
|
||||
|
||||
@@ -35,16 +35,24 @@ const COLOR_MOISTURE_WET: Color = Color(0.25, 0.72, 0.65, 1.0)
|
||||
const REGION_TEMP_NONE_DC: int = AtlasOverlayColors.REGION_TEMP_NONE_DC
|
||||
|
||||
|
||||
## One decoded plane set, pre-extracted from the four L8 Images + the two
|
||||
## MorphologyZone::Lake discriminant (D-239 §6 order, T-1046) — matches
|
||||
## AtlasOverlayColors.MORPHOLOGY_LAKE (duplicated here per this file's own
|
||||
## "no cross-file constant sharing beyond the preloaded module" precedent,
|
||||
## same as step_canvas_protocol.gd's status-decode duplication rationale).
|
||||
const MORPHOLOGY_LAKE: int = 1
|
||||
|
||||
## One decoded plane set, pre-extracted from the five L8 Images + the two
|
||||
## raw-array fields a caller needs per cell — built once per arrived canvas
|
||||
## (see StepCanvasTerrainLayer.build_texture()), not re-decoded per pixel.
|
||||
## `temp_dc`/`settlement_id` stay as plain Arrays (their domain doesn't fit
|
||||
## a byte plane — see step_canvas_protocol.gd's own doc).
|
||||
## a byte plane — see step_canvas_protocol.gd's own doc). `lake_margin_q`
|
||||
## added T-1188 (the lake-shoreline tone source).
|
||||
class CellPlanes:
|
||||
var morphology: Image
|
||||
var elev_q: Image
|
||||
var moisture_q: Image
|
||||
var vegetation: Image
|
||||
var lake_margin_q: Image
|
||||
var glaciation: Image
|
||||
var temp_dc: Array
|
||||
var width: int
|
||||
@@ -72,8 +80,16 @@ static func cell_color(planes: CellPlanes, col: int, row: int, active_toggle: St
|
||||
|
||||
static func _base_color(planes: CellPlanes, col: int, row: int) -> Color:
|
||||
var zone: int = _l8_value(planes.morphology, col, row)
|
||||
var eq: int = _l8_value(planes.elev_q, col, row)
|
||||
var base: Color = AtlasOverlayColors.district_window_morphology_color(zone)
|
||||
# T-1188: Lake cells shade by lake_margin_q (the settled-hydrology depth
|
||||
# band), NOT elev_q — elev_q on a Lake cell is the ORIGINAL bedrock
|
||||
# elevation under the water (see DistrictProfile.elev_q's own doc), an
|
||||
# unrelated signal that would shade the wrong thing. Every other zone
|
||||
# keeps the existing elev_q lightness read unchanged.
|
||||
if zone == MORPHOLOGY_LAKE:
|
||||
var lmq: int = _l8_value(planes.lake_margin_q, col, row)
|
||||
return AtlasOverlayColors.lake_margin_lightness(base, lmq)
|
||||
var eq: int = _l8_value(planes.elev_q, col, row)
|
||||
return AtlasOverlayColors.district_window_elevation_lightness(base, eq)
|
||||
|
||||
|
||||
|
||||
@@ -86,9 +86,11 @@ func rebuild_from_canvas(canvas: Dictionary, rung: String, active_toggle: String
|
||||
queue_redraw()
|
||||
|
||||
|
||||
## Decode the four L8-plane PackedByteArrays (via Image.load_png_from_buffer,
|
||||
## per step_canvas_protocol.gd's own PNG-per-field wire note) plus the two
|
||||
## raw arrays into one CellPlanes bundle, once per rebuild.
|
||||
## Decode the five L8-plane PackedByteArrays (morphology/elev_q/moisture_q/
|
||||
## vegetation/lake_margin_q — glaciation is the sixth, decoded separately
|
||||
## below) via Image.load_png_from_buffer (per step_canvas_protocol.gd's own
|
||||
## PNG-per-field wire note) plus the raw temp_dc array into one CellPlanes
|
||||
## bundle, once per rebuild. `lake_margin_q` added T-1188.
|
||||
func _decode_planes(canvas: Dictionary, width: int, height: int) -> StepCanvasColorize.CellPlanes:
|
||||
var planes := StepCanvasColorize.CellPlanes.new()
|
||||
planes.width = width
|
||||
@@ -97,6 +99,7 @@ func _decode_planes(canvas: Dictionary, width: int, height: int) -> StepCanvasCo
|
||||
planes.elev_q = _decode_l8_plane(canvas.get("elev_q"))
|
||||
planes.moisture_q = _decode_l8_plane(canvas.get("moisture_q"))
|
||||
planes.vegetation = _decode_l8_plane(canvas.get("vegetation"))
|
||||
planes.lake_margin_q = _decode_l8_plane(canvas.get("lake_margin_q"))
|
||||
planes.glaciation = _decode_l8_plane(canvas.get("glaciation"))
|
||||
planes.temp_dc = canvas.get("temp_dc", [])
|
||||
return planes
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
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.0
|
||||
version: 0.4.1
|
||||
repository: settled-reach
|
||||
|
||||
|
||||
|
||||
@@ -711,6 +711,7 @@ mod tests {
|
||||
slope_q,
|
||||
elev_q,
|
||||
ocean_fraction_q,
|
||||
lake_margin_q: 0,
|
||||
river_threshold: 200,
|
||||
temperature_c: Some(15.0),
|
||||
moisture_q,
|
||||
|
||||
@@ -500,6 +500,7 @@ mod tests {
|
||||
slope_q: 5,
|
||||
elev_q: 20,
|
||||
ocean_fraction_q: 15,
|
||||
lake_margin_q: 0,
|
||||
river_threshold: 200,
|
||||
temperature_c: Some(18.0),
|
||||
moisture_q: 55,
|
||||
@@ -637,6 +638,7 @@ mod tests {
|
||||
slope_q: 3,
|
||||
elev_q: 10,
|
||||
ocean_fraction_q: 0, // no water at all
|
||||
lake_margin_q: 0,
|
||||
river_threshold: 300,
|
||||
temperature_c: Some(35.0),
|
||||
moisture_q: 5,
|
||||
|
||||
@@ -226,6 +226,24 @@ pub struct DistrictProfile {
|
||||
/// Ocean fraction for this district (0–100 scale, integer).
|
||||
pub ocean_fraction_q: i32,
|
||||
|
||||
/// 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.
|
||||
#[serde(default)]
|
||||
pub lake_margin_q: i32,
|
||||
|
||||
/// Per-district river threshold (D-239 §1). Replaces the global
|
||||
/// `RIVER_THRESHOLD = 200` for tile-layer consumers. The drainage constant
|
||||
/// itself is unchanged — this value is what `DistrictProfile` carries downstream.
|
||||
@@ -1522,15 +1540,19 @@ pub fn derive_district_profile(
|
||||
/// is threaded straight through to `derive_vegetation` unchanged, after every
|
||||
/// moisture/temperature/morphology field above it has already been resolved.
|
||||
///
|
||||
/// ## Lake sourcing (T-1184, D-227 amendment (4))
|
||||
/// ## Lake sourcing (T-1184, D-227 amendment (4); T-1188 depth band)
|
||||
///
|
||||
/// `lake_from_hydrology` is the caller-computed [`lake_from_hydrology_at`]
|
||||
/// verdict for this position — threaded straight into
|
||||
/// [`derive_morphology_zone`]'s new gate, ahead of its pre-existing
|
||||
/// `ocean_fraction_q >= 60` heuristic. Computed by the caller (not here) for
|
||||
/// the same reason `near_perennial_water` is: this function stays free of
|
||||
/// `TerrainAnalysis`/pixel-position concerns, taking only the already-reduced
|
||||
/// per-position signals every other field here consumes.
|
||||
/// `ocean_fraction_q >= 60` heuristic. `lake_margin_q` is the SAME call's
|
||||
/// depth-band quantization, threaded straight onto the output profile
|
||||
/// (T-1188 — no further derivation needed; see `lake_from_hydrology_at`'s
|
||||
/// doc for what it represents and why lakes need it where oceans don't).
|
||||
/// Computed by the caller (not here) for the same reason `near_perennial_water`
|
||||
/// is: this function stays free of `TerrainAnalysis`/pixel-position concerns,
|
||||
/// taking only the already-reduced per-position signals every other field
|
||||
/// here consumes.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_district_profile(
|
||||
seed: SeedChain,
|
||||
@@ -1546,6 +1568,7 @@ fn build_district_profile(
|
||||
min_wavelength_m: f64,
|
||||
near_perennial_water: bool,
|
||||
lake_from_hydrology: bool,
|
||||
lake_margin_q: i32,
|
||||
) -> DistrictProfile {
|
||||
let tectonic_class = derive_tectonic_class(body_params);
|
||||
|
||||
@@ -1635,6 +1658,7 @@ fn build_district_profile(
|
||||
slope_q,
|
||||
elev_q,
|
||||
ocean_fraction_q,
|
||||
lake_margin_q,
|
||||
river_threshold,
|
||||
temperature_c,
|
||||
moisture_q,
|
||||
@@ -1876,7 +1900,9 @@ fn derive_at_metres_with_riparian(
|
||||
// T-1184: the settled-hydrology lake test, sampled at the SAME (px, py)
|
||||
// fractional working-grid position every other envelope field here reads
|
||||
// — the continuous filled-surface comparison (D-227 amendment (4)).
|
||||
let lake_from_hydrology = lake_from_hydrology_at(ta, px, py);
|
||||
// T-1188: the same sample also yields the depth-band tone source
|
||||
// (`lake_margin_q`) lake shorelines were missing.
|
||||
let (lake_from_hydrology, lake_margin_q) = lake_from_hydrology_at(ta, px, py);
|
||||
|
||||
build_district_profile(
|
||||
seed,
|
||||
@@ -1892,6 +1918,7 @@ fn derive_at_metres_with_riparian(
|
||||
min_wavelength_m,
|
||||
near_perennial_water,
|
||||
lake_from_hydrology,
|
||||
lake_margin_q,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2008,7 +2035,8 @@ pub fn derive_orbital_at_metres(
|
||||
// T-1184: same continuous filled-surface comparison every rung samples,
|
||||
// at the orbital rung's own (px, py) — lake edges refine at Region
|
||||
// spacing exactly as they do at every finer rung (D-227 amendment (4)).
|
||||
let lake_from_hydrology = lake_from_hydrology_at(ta, px, py);
|
||||
// T-1188: the same sample also yields the depth-band tone source.
|
||||
let (lake_from_hydrology, lake_margin_q) = lake_from_hydrology_at(ta, px, py);
|
||||
|
||||
build_district_profile(
|
||||
seed,
|
||||
@@ -2039,6 +2067,7 @@ pub fn derive_orbital_at_metres(
|
||||
// spacing and could never fire (Ruling 4e).
|
||||
false,
|
||||
lake_from_hydrology,
|
||||
lake_margin_q,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2072,29 +2101,74 @@ 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;
|
||||
|
||||
/// The T-1184 settled-hydrology lake test (D-227 amendment (4) / D-255(f)
|
||||
/// mechanism B): `true` when a bilinear sample of the settled filled-surface
|
||||
/// field strictly exceeds a bilinear sample of the original elevation at the
|
||||
/// SAME fractional working-grid position — the continuous comparison that
|
||||
/// makes lake edges refine with rung exactly like coastlines, rather than
|
||||
/// projecting `HydrologyResult.basins[*].cells` membership as a discrete,
|
||||
/// non-refining lookup (explicitly rejected, see this function's callers'
|
||||
/// docs). `false` when `ta.hydrology` is `None` (no solve available for this
|
||||
/// analysis — every caller must already treat `false` here as "fall through
|
||||
/// to the `ocean_fraction_q` heuristic", never as an error).
|
||||
/// mechanism B) PLUS its T-1188 depth-band extension. Returns
|
||||
/// `(is_lake, lake_margin_q)`:
|
||||
///
|
||||
/// - `is_lake` — `true` when a bilinear sample of the settled filled-surface
|
||||
/// field strictly exceeds a bilinear sample of the original elevation at
|
||||
/// the SAME fractional working-grid position — the continuous comparison
|
||||
/// that makes lake edges refine with rung exactly like coastlines, rather
|
||||
/// than projecting `HydrologyResult.basins[*].cells` membership as a
|
||||
/// discrete, non-refining lookup (explicitly rejected, see this function's
|
||||
/// callers' docs). `false` when `ta.hydrology` is `None` (no solve
|
||||
/// available for this analysis — every caller must already treat `false`
|
||||
/// 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 —
|
||||
/// `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,
|
||||
/// Estuarine — all keyed on `ocean_fraction_q >= N`) is structurally
|
||||
/// unreachable at a lake edge even though the underlying position
|
||||
/// sampling refines correctly with rung (verified: a shoreline-crossing
|
||||
/// sweep at district/quarter/block spacing lands on the exact same
|
||||
/// continuous world-metres crossing at every rung, and a 10 m fine sweep
|
||||
/// confirms sub-block precision — the T-1188 hypothesis (a) positional
|
||||
/// 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).
|
||||
fn lake_from_hydrology_at(ta: &TerrainAnalysis, px: f64, py: f64) -> bool {
|
||||
/// 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.
|
||||
fn lake_from_hydrology_at(ta: &TerrainAnalysis, px: f64, py: f64) -> (bool, i32) {
|
||||
let Some(h) = ta.hydrology.as_ref() else {
|
||||
return false;
|
||||
return (false, 0);
|
||||
};
|
||||
let filled = bilinear(&h.filled, ta.w, ta.h, px, py);
|
||||
let original = bilinear(&h.elevation, ta.w, ta.h, px, py);
|
||||
filled > original
|
||||
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
|
||||
} else {
|
||||
0
|
||||
};
|
||||
(is_lake, lake_margin_q)
|
||||
}
|
||||
|
||||
/// Bilinear interpolation of a boolean mask as a 0–1 fraction (for ocean coverage).
|
||||
|
||||
@@ -2441,6 +2441,7 @@ mod tests {
|
||||
slope_q: 0,
|
||||
elev_q: elev,
|
||||
ocean_fraction_q: 0,
|
||||
lake_margin_q: 0,
|
||||
river_threshold: 200,
|
||||
temperature_c: Some(10.0),
|
||||
moisture_q: 50,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -2291,6 +2291,7 @@ mod tests {
|
||||
slope_q: 5,
|
||||
elev_q: 20,
|
||||
ocean_fraction_q: 15,
|
||||
lake_margin_q: 0,
|
||||
river_threshold: 200,
|
||||
temperature_c: Some(18.0),
|
||||
moisture_q: 55,
|
||||
@@ -2312,6 +2313,7 @@ mod tests {
|
||||
slope_q,
|
||||
elev_q: 5,
|
||||
ocean_fraction_q: 90,
|
||||
lake_margin_q: 0,
|
||||
river_threshold: 200,
|
||||
temperature_c: Some(10.0),
|
||||
moisture_q: 80,
|
||||
@@ -2857,6 +2859,7 @@ mod tests {
|
||||
slope_q: 5,
|
||||
elev_q: 20, // low elevation
|
||||
ocean_fraction_q: 0, // no channel — simpler tile layout for elevation check
|
||||
lake_margin_q: 0,
|
||||
river_threshold: 200,
|
||||
temperature_c: Some(18.0),
|
||||
moisture_q: 55,
|
||||
@@ -2871,6 +2874,7 @@ mod tests {
|
||||
slope_q: 5,
|
||||
elev_q: 70, // high elevation — makes the blend measurable
|
||||
ocean_fraction_q: 0,
|
||||
lake_margin_q: 0,
|
||||
river_threshold: 200,
|
||||
temperature_c: Some(18.0),
|
||||
moisture_q: 55,
|
||||
|
||||
@@ -2269,6 +2269,7 @@ fn make_region(
|
||||
slope_q,
|
||||
elev_q,
|
||||
ocean_fraction_q,
|
||||
lake_margin_q: 0,
|
||||
river_threshold: derive_river_threshold(tectonic, precip),
|
||||
temperature_c,
|
||||
moisture_q,
|
||||
|
||||
Reference in New Issue
Block a user