feat(client): T-1153 + T-1152 client half — continuous cursor-anchored zoom ladder, Region-rung orbital entry, click-through retired

The atlas 'regional' screen now opens the LADDER at the canonical orbital
frame (Region granularity, whole body fitted and centered) and wheel zoom
descends continuously — cursor-anchored, unclamped across rungs, with
progressive refinement (held composite keeps drawing, finer rung swaps in
place on arrival; no blank frame, no mode flip). Full-zoom-out resets to
the canonical planetary frame per Jeroen's HARD condition
(is_fully_zoomed_out = extent >= body circumference, not a zoom-value
heuristic). The district_screen nav hop is deleted — D-013 restored:
descent is a zoom gesture, not a nav push. AtlasViewer's heightmap-texture
path is unreachable from nav (code intact; overlay surface deferred, see
report/tickets).

Rung selection: design doc §5's literal formula has NO legal District band
at any real viewport (visual-tolerance band and n=64 coverage ceiling
never overlap — pinned by executable boundary tests at 1600x900);
select_rung() splits it into a coverage ceiling (decides Region) then the
2x visual tolerance (District vs Quarter), documented at the function.
In practice the ladder steps Region -> Quarter directly.

Wire: window_granularity_v2 encoded (omitted at District for byte-compat),
granularity_v2 echoed value keyed + staleness-guarded end to end; Region
clamp mirror replicates the server's bounded halving loop (no closed
form). MIN/MAX_ZOOM widened to [0.0005, 64] — the old 0.5 floor would
have clamped a real body's canonical fit zoom, violating the reset
condition.

Real pre-existing bug fixed in atlas_window_overlay.gd: the draw path used
echoed n as both cell-grid dimension and district extent — only
coincidentally correct at District granularity; Quarter/Region would have
read wrong array offsets. cell_grid_side_for_window() now mirrors the
server's WindowGranularity::cell_grid_side.

Tests: +26 pure-function geometry tests, new 30-test zoom-ladder suite,
extensions across the window cache/request/overlay/delivery suites.
Full suite 3518 green; cold-parse clean.
This commit is contained in:
2026-07-22 11:41:37 +02:00
parent d356b09926
commit ce90d69ae8
19 changed files with 2144 additions and 336 deletions
+16 -4
View File
@@ -435,20 +435,32 @@ func send_named_action(action_name: String, action_data: Variant = null) -> void
##
## window_granularity/window_min_wl_m (T-1150): struct/key plumbing for the
## zoom-ladder quarter rung — district (0/omitted) stays the default for
## every caller in this codebase today; requesting quarter granularity is
## T-1153's job, not wired here.
## every caller in this codebase today.
##
## window_granularity_v2 (T-1152/T-1153): the R5-redesigned string-tag
## granularity ("Quarter"/"District"/"Region") — the ONLY way to request the
## coarser-than-district Region rung the legacy u32 field cannot express.
## Empty string (omitted) is the default for every caller that doesn't pass
## it, byte-compatible with every pre-T-1152 request.
func request_atlas_layers(
body_id: String,
up_to: String = "Topography",
window_center: Variant = null,
window_n: int = 0,
window_granularity: int = 0,
window_min_wl_m: int = 0
window_min_wl_m: int = 0,
window_granularity_v2: String = ""
) -> void:
if test_mode or _bridge == null or state != ConnectionState.CONNECTED:
return
var bytes := Protocol.encode_atlas_layer_request(
body_id, up_to, window_center, window_n, window_granularity, window_min_wl_m
body_id,
up_to,
window_center,
window_n,
window_granularity,
window_min_wl_m,
window_granularity_v2
)
if bytes.is_empty():
return
+23 -2
View File
@@ -40,6 +40,22 @@ class_name AtlasMapProtocol
## it possible to ask, byte-compatible with every existing caller that
## doesn't pass them.
##
## `window_granularity_v2` (T-1152, R5 redesign — see
## server/src/atlas/layer_proxy.rs's `WindowGranularity` doc): the ONLY way to
## express a coarser-than-district rung (`"Region"`) the legacy `u32` field
## cannot encode. A plain STRING variant tag ("Quarter" | "District" |
## "Region"), matching `RoadNodeKind`'s existing wire precedent on this same
## carrier (a bare `#[derive(Serialize, Deserialize)]` enum with no
## `#[serde(rename_all)]` — rmp_serde encodes the Rust variant NAME verbatim,
## not an integer discriminant). OMITTED (not sent as "") when
## `window_granularity_v2` is the empty string — `#[serde(default)]` on the
## Rust side decodes absence as `None`, falling back to the legacy `u32`
## field's `resolve_window_granularity_v2()` precedence rule (that field wins
## over the legacy one whenever present — see that Rust doc for the full
## precedence contract). Every pre-T-1152 caller (and every T-1150 caller that
## only ever sends `window_granularity`) omits this field entirely and stays
## byte-compatible.
##
## **Quantization split (PR #191 review, Hoshe 1 / Tyre C3):** `window_min_wl_m`
## is sent HERE as a raw, unquantized value — this codec does NOT snap it to
## the design doc §5 fixed band set. The SERVER is the one place quantization
@@ -56,7 +72,8 @@ static func encode_atlas_layer_request(
window_center: Variant = null,
window_n: int = 0,
window_granularity: int = 0,
window_min_wl_m: int = 0
window_min_wl_m: int = 0,
window_granularity_v2: String = ""
) -> PackedByteArray:
var msg := {"body_id": body_id, "up_to": up_to}
if window_center != null:
@@ -67,6 +84,8 @@ static func encode_atlas_layer_request(
msg["window_granularity"] = window_granularity
if window_min_wl_m != 0:
msg["window_min_wl_m"] = window_min_wl_m
if not window_granularity_v2.is_empty():
msg["window_granularity_v2"] = window_granularity_v2
var result = mp.encode(msg)
if result.status != null:
push_error("Protocol: encode_atlas_layer_request failed: %s" % result.status)
@@ -91,7 +110,9 @@ static func encode_atlas_layer_request(
## wire decodes to GDScript `null` exactly like every other Option field here.
## The response's `center`/`n` echo (inside the layer dict itself) is the
## client's race-condition/staleness guard (§2) — read by the window cache,
## not unwrapped here.
## not unwrapped here. `granularity_v2` (T-1152) rides inside the same dict,
## a bare string variant tag ("Quarter"/"District"/"Region") — no separate
## top-level unwrap needed, it passes through with everything else.
## Key names "road_graph"/"settlements"/"region_grid"/"quarter_footprints"/
## "district_window" are the CONFIRMED wire contract — identical to
## server/src/atlas/layer_proxy.rs AtlasLayerResponse's field names
+13 -2
View File
@@ -782,16 +782,27 @@ static func encode_request_bookmark_catalog() -> PackedByteArray:
## Omitted callers (every whole-body-layer call site predating T-1138) are
## byte-unchanged. window_granularity/window_min_wl_m (T-1150): same
## byte-compatibility contract, see atlas_map_protocol.gd.
## window_granularity_v2 (T-1152): the R5-redesigned string-tag granularity
## ("Quarter"/"District"/"Region") — the only way to request the Region rung.
## Omitted (empty string) by every caller that doesn't pass it.
static func encode_atlas_layer_request(
body_id: String,
up_to: String = "Topography",
window_center: Variant = null,
window_n: int = 0,
window_granularity: int = 0,
window_min_wl_m: int = 0
window_min_wl_m: int = 0,
window_granularity_v2: String = ""
) -> PackedByteArray:
return _amp().encode_atlas_layer_request(
_mp(), body_id, up_to, window_center, window_n, window_granularity, window_min_wl_m
_mp(),
body_id,
up_to,
window_center,
window_n,
window_granularity,
window_min_wl_m,
window_granularity_v2
)