feat(ui): step-canvas map component — RTT terrain + stepped zoom (D-255, T-1182)

The two-layer client rebuild per D-255(a)(b)(e), replacing the
_canvas.scale continuous-zoom model with one viewer, one path, all six
rungs:

- step_canvas_protocol.gd: StepCanvasRequest/Response codec against
  the T-1181 wire contract — incl. the discovered png_bytes subtlety
  (rmp_serde without serde_bytes emits a msgpack int-array, not bin;
  decode repacks via PackedByteArray before load_png_from_buffer) and
  the extent-echo rule (read the server-clamped extent, never assume
  the requested one).
- step_canvas/ component: transport (six-rung ladder, cursor-anchored
  scroll steps, edge-scroll/WASD pan with re-request on edge crossing,
  hard reset-to-Global), RTT terrain layer (Image.set_pixel colorize
  per the c1 measured ruling, texture.update reuse on step-cross,
  NEAREST coarse / LINEAR fine per rung), unscaled screen-space
  annotation sibling (courses + settlement markers at literal px),
  in-memory LRU cache (Tier 1; T-1183 layers the disk tiers beneath),
  request lifecycle (pending retry, staleness gate, extent echo).
- Full _canvas.scale retirement in the same change: the zoom-scaled
  canvas model, the _zs compensation family, select_rung /
  MAX_COVERAGE_M / compute_tile_grid, the orbital-mosaic-vs-window
  two-path split, _view_zoom/_canonical_fit_zoom — 10 source files
  deleted; their 14 test suites deleted with them (T-1157 dead-goldens
  rule; replacement visual-capture coverage is re-scoped T-1157).
- Surviving surfaces kept per the ticket: atlas_window_cache.gd's LRU
  shape (the ticket's named file atlas_window_tile_set.gd was the
  retiring orchestrator; the real LRU shape lives in
  atlas_window_cache.gd — cited in step_canvas_cache.gd), overlay
  colors, legend/overlay-bar chrome, AtlasViewer descend geometry.

Determinism boundary per D-255(e): the client interpolates only within
the closed server-supplied input set. 7 new gdUnit suites (164 cases)
incl. a real extent-echo bug caught by its own test during
implementation. Full client suite green (exit 0) with the live-gated
suites running against a worktree server build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 08:53:48 +02:00
co-authored by Claude Fable 5
parent e0a3ab35cf
commit d28d24fd26
43 changed files with 2858 additions and 12241 deletions
+4 -4
View File
@@ -76,10 +76,10 @@ func _unhandled_key_input(event: InputEvent) -> void:
return
if not event.is_pressed() or event.is_echo():
return
# "regional" (the whole zoom ladder, AtlasWindowViewer, T-1153) handles its
# own Esc via _gui_input — a stray M/other unhandled key on that screen
# must not ALSO fire this app's own _handle_key underneath the viewer's
# own handling.
# "regional" (the whole stepped zoom ladder, StepCanvasViewer, T-1182)
# handles its own Esc via _gui_input — a stray M/other unhandled key on
# that screen must not ALSO fire this app's own _handle_key underneath
# the viewer's own handling.
if current_screen_id() == "regional":
return
_handle_key(event as InputEventKey)
@@ -1,727 +0,0 @@
extends RefCounted
## Pure geometry helpers for AtlasWindowViewer's fit/pan transform (T-1142 —
## Jeroen's second hands-on finding: enter() reset zoom to 1.0/offset to ZERO
## with no fit, so an n=32 composite (512px native) rendered as a postage
## stamp in a ~1900px viewport). Factored out of atlas_window_viewer.gd for
## the same reason atlas_descend_geometry.gd was factored out of
## atlas_viewer.gd (T-1138): the actual _canvas.position/.scale WRITES stay on
## the viewer (Node-tree side effects), but the pure "given a viewport and a
## window size, what zoom/offset centers it" math is unit-testable in
## isolation here — a caller does:
## const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
##
## T-1153: also carries the rung-selection rule (design doc
## docs/architecture/atlas-zoom-ladder-t1143.md §5) and the "fully zoomed
## out" reset predicate (Jeroen's D-226 T-1143-rulings HARD condition) — both
## pure functions of (viewport, held state, body), same "geometry lives here,
## side effects live on the viewer" split as the rest of this file. Live
## round 3 also adds the orbital-rest-state TILE GRID computation
## (compute_tile_grid(), near the bottom) — reuses
## AtlasDescendGeometry.district_extent()/canonicalize_district_center() for
## the SAME wrap/clamp discipline every other piece of this cluster already
## depends on, hence the preload below (no circular dependency:
## atlas_descend_geometry.gd never references this file).
##
## T-1170: the T-1156 wave-1 nature-overlay (river/basin/attractor) pixel
## mapping and per-rung visibility policy (RIVER_CLASS_*, layer1_pixel_to_*,
## *_visible_at_rung, zoom_compensated_size) moved OUT of this file to
## atlas_window_geometry_nature.gd (this file was at 954/1000 gdlint
## max-file-lines when the move happened) — see that file's own header doc.
## cell_index_for_local_offset() (T-1172, near the bottom of this file) stayed
## here since it is shared with AtlasWindowOverlay's terrain painter, a
## non-nature consumer.
const AtlasDescendGeometryRef := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd")
## D-243 rung spacings, metres/cell — the SAME constants
## server/src/atlas/scale.rs and layer_proxy.rs's WindowGranularity::spacing_m
## source from, mirrored here rather than re-derived so the client's rung
## table can never silently drift from the wire contract it's choosing
## between.
const QUARTER_SPACING_M: float = 512.0
const DISTRICT_SPACING_M: float = 2048.0
const REGION_SPACING_M: float = 204_800.0
## Table form, coarsest-first — spacing_for_rung()'s inverse lookup walks
## this. select_rung() (below) does NOT walk this table directly — see that
## function's own doc for why the coarse (Region) and fine (District/
## Quarter) ends are decided by two DIFFERENT tests, not a single ordered
## table scan.
const RUNG_TABLE: Array = [
{"granularity_v2": "Region", "spacing_m": REGION_SPACING_M},
{"granularity_v2": "District", "spacing_m": DISTRICT_SPACING_M},
{"granularity_v2": "Quarter", "spacing_m": QUARTER_SPACING_M},
]
## Server per-axis cap on a District/Quarter-granularity window's `n`
## (mirrors server/src/atlas/layer_proxy.rs's `DISTRICT_WINDOW_MAX_N` — see
## AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N, the existing client-side
## mirror of the same constant, kept in sync there).
const DISTRICT_WINDOW_MAX_N: int = 64
## Wire-size ceiling (mirrors AtlasWindowRequest.SERVER_WIRE_CAP_CELLS /
## server/src/atlas/layer_proxy.rs's WIRE_CAP_CELLS) — the cell-count cap
## EVERY rung's single window is clamped against, per
## `_clamp_window_n_mirror`/`_clamp_window_n_mirror_v2`'s own formulas.
const WIRE_CAP_CELLS: int = 4_096
## Region's own per-axis ceiling (mirrors
## AtlasWindowRequest.SERVER_DISTRICT_WINDOW_MAX_N_REGION /
## server/src/atlas/layer_proxy.rs's DISTRICT_WINDOW_MAX_N_REGION).
const DISTRICT_WINDOW_MAX_N_REGION: int = 6_400
## Per-tile district extent for the orbital-rest-state tile grid
## (compute_tile_grid(), near the bottom of this file) — the SAME `n` a
## single Region request uses at its own per-axis ceiling. Each tile
## requests exactly this many districts on a side — the largest single
## window the wire budget allows, so tiling uses the FEWEST tiles that can
## cover a given body.
const TILE_N: int = DISTRICT_WINDOW_MAX_N_REGION
## **Live round 3 finding (the actual root cause of "zoom-driven rung
## reselection never fires"):** each rung's SINGLE WINDOW has a hard MAXIMUM
## real-world coverage, derived from the SAME wire-size clamp
## (`_clamp_window_n_mirror_v2`) the request layer already enforces — District
## and Quarter are NOT exempt from this the way the original (§5-literal)
## design assumed. A rung whose own single-window coverage is smaller than
## the CURRENTLY DISPLAYED world extent cannot legally be selected: the
## server would clamp `n` down to fit its own wire budget, producing a
## composite that covers only a FRACTION of the viewport — visually a tiny
## box in the middle of the screen, and (the bug this constant's discovery
## fixes) a composite whose CLAMPED `n` no longer matches whatever `_held_n`
## the viewer was still carrying from the PREVIOUS rung, permanently failing
## `_on_window_ready()`'s staleness check. Computed here ONCE, from the same
## constants `_clamp_window_n_mirror_v2` uses, rather than re-derived per
## rung inline — see MAX_COVERAGE_M below.
##
## - Quarter: per-axis cap `floor(sqrt(WIRE_CAP_CELLS)/4) = 16` districts ->
## cell-grid side `16*4 = 64` cells -> `64 * QUARTER_SPACING_M = 32,768 m`.
## - District: per-axis cap `floor(sqrt(WIRE_CAP_CELLS)/1) = 64` districts ->
## `64 * DISTRICT_SPACING_M = 131,072 m` (unchanged from the original
## coverage-ceiling constant this replaces/generalizes).
## - Region: per-axis cap `DISTRICT_WINDOW_MAX_N_REGION = 6,400` districts ->
## `6,400 * DISTRICT_SPACING_M = 13,107,200 m` — this is a SINGLE window's
## ceiling; bug B's progressive tiling composes MULTIPLE Region windows to
## cover extents beyond this (see the viewer's tile-set model), so this
## constant alone does NOT bound what the ORBITAL REST STATE can show —
## only what one Region REQUEST's response covers.
const MAX_COVERAGE_M: Dictionary = {
"Quarter": 64.0 * QUARTER_SPACING_M,
"District": float(DISTRICT_WINDOW_MAX_N) * DISTRICT_SPACING_M,
"Region": float(DISTRICT_WINDOW_MAX_N_REGION) * DISTRICT_SPACING_M,
}
## Rungs ordered FINEST-first — select_rung() walks this to find the finest
## rung whose own single-window coverage ceiling still covers the current
## extent (never a rung that would silently under-cover the viewport).
const RUNGS_FINEST_FIRST: Array = ["Quarter", "District", "Region"]
## Fit-and-center: given the viewport size and the window's side length in
## districts, compute the zoom/offset that COVERS the viewport (fills it edge
## to edge, no side margins) and centers the composite. Mirrors AtlasViewer's
## own _fit_to_view() shape (fit, then center) but as a pure function
## returning {zoom, offset} instead of writing _view_zoom/_view_offset
## directly, so AtlasWindowViewer.enter()/(_on_window_ready)/NOTIFICATION_RESIZED
## can all call the SAME formula without three copies of the math drifting.
##
## T-1145 item 1 (Jeroen's round-2 finding, KALLAST window): the ORIGINAL fit
## was CONTAIN (zoom from the SMALLER viewport dimension, with a 0.9 margin
## factor) — in a wide viewport this left large side margins around a square
## composite (the window data is always n x n, a square, regardless of
## viewport aspect). Changed to COVER: zoom from the LARGER viewport
## dimension, with NO margin factor — a margin on the CONTAIN axis (the one
## the zoom is computed from) is a deliberate breathing-room choice; the
## exact same margin on the COVER axis would be a literal gap at the
## viewport's own edge, which is precisely the "no side margins" defect this
## fix removes. The composite therefore fills the screen edge to edge on its
## long axis (scaled side == max(viewport.x, viewport.y) exactly) and
## overhangs past both edges on its short axis (exactly the same "cover"
## concept CSS background-size/object-fit use — fill the frame, crop what
## doesn't fit, never letterbox). This is honest for a square dataset in a
## non-square frame: at rest, the player sees a full-bleed slice of the
## window, and panning (T-1145 item 2: WASD/edge-scroll) reveals the rest,
## including triggering the existing pan-edge refetch (§4) exactly as
## intended — cover does not change what "past the window edge" means, only
## how much of the window is visible before the player pans at all.
##
## zoom = clampf(max(viewport.x, viewport.y) / (n * cell_px), MIN_ZOOM, MAX_ZOOM)
## offset centers the (n * cell_px * zoom)-sized composite on the viewport,
## exactly as the old contain fit did.
static func fit_window_view(
viewport: Vector2, n: int, cell_px: float, min_zoom: float, max_zoom: float
) -> Dictionary:
if n <= 0 or cell_px <= 0.0 or viewport.x <= 0.0 or viewport.y <= 0.0:
return {"zoom": 1.0, "offset": Vector2.ZERO}
var composite_native: float = float(n) * cell_px
var zoom: float = clampf(maxf(viewport.x, viewport.y) / composite_native, min_zoom, max_zoom)
var composite_scaled: Vector2 = Vector2(composite_native, composite_native) * zoom
var offset: Vector2 = (viewport - composite_scaled) * 0.5
return {"zoom": zoom, "offset": offset}
## T-1142 addendum (Jeroen — pole-wall ruling): the pan offset's Y component
## must never let the WINDOW EDGE (not merely the window center) cross the
## body's row extent — panning past a pole would ask the derive for rows
## beyond ±rows_half, which the server clamps (T-1142's own
## normalize_window_center) into a smeared repeated-clamped-latitude band,
## not real topology. The wall is therefore drawn at the edge of the ACTUAL
## valid row range, honestly reflecting "this is where the world ends", not
## an arbitrary UI limit.
##
## Inputs are all in the SAME units the caller's _view_offset/_view_zoom
## already use (canvas px = district cells * cell_px, screen px after zoom):
## `held_center`/`held_n` describe the currently-fetched window (its center
## district row and side length); `rows_half` is the body's half-meridian
## extent in districts (district_extent()'s "rows_half", i.e. equator-to-pole
## in whole districts); `cell_px`/`zoom` convert districts to screen pixels.
## Returns the Y-clamped offset — X is untouched (no wall on longitude, T-1142
## item 6: circumnavigation is seamless, only the row axis is a hard boundary).
static func clamp_pan_offset_to_pole_wall(
offset: Vector2,
view_size: Vector2,
held_center: Vector2i,
held_n: int,
rows_half: int,
cell_px: float,
zoom: float
) -> Vector2:
if rows_half <= 0 or held_n <= 0 or cell_px <= 0.0 or zoom <= 0.0:
return offset
# The held window spans districts [held_center.y - held_n/2, held_center.y
# + held_n/2) — its top/bottom edges in ABSOLUTE district-row space.
var half_n: float = float(held_n) * 0.5
var window_top_row: float = float(held_center.y) - half_n
var window_bottom_row: float = float(held_center.y) + half_n
# Canvas-space (pre-zoom) distance from the window's local origin (row 0
# of the held composite, i.e. window_top_row) to the pole boundary rows.
# A pole boundary that falls OUTSIDE the held window's own row span is not
# reachable by panning within this fetch at all (clampf below is then a
# no-op in that direction) — the wall only bites once a pan would expose
# rows the held window doesn't cover AND those rows would cross the pole.
var north_wall_local_row: float = float(-rows_half) - window_top_row
var south_wall_local_row: float = float(rows_half) - window_top_row
# Screen-space Y bound: offset.y is the screen position of canvas-Y=0
# (the composite's top edge). Moving the composite DOWN (offset.y
# increasing) reveals rows ABOVE window_top_row — i.e. moves the visible
# top edge toward the north wall. The composite's top edge, in canvas
# units, must never be dragged past the north wall's canvas position, and
# the bottom edge (view_size.y below the top, in screen space) must never
# be dragged past the south wall's.
var north_wall_screen_y: float = -north_wall_local_row * cell_px * zoom
var south_wall_screen_y: float = view_size.y - south_wall_local_row * cell_px * zoom
# offset.y is clamped so the top edge never exceeds the north wall
# (offset.y <= north_wall_screen_y keeps the top edge from being pulled
# DOWN past the wall — i.e. revealing north of it) and the bottom edge
# never exceeds the south wall on the other side. When the window's own
# span doesn't reach a wall, that wall's bound is on the permissive side
# of the other and clampf's min/max ordering still holds (min >= max only
# when BOTH walls are inside the span and the window is taller than the
# pole-to-pole distance — see the "tiny body" doc note on the caller).
var min_y: float = minf(north_wall_screen_y, south_wall_screen_y)
var max_y: float = maxf(north_wall_screen_y, south_wall_screen_y)
return Vector2(offset.x, clampf(offset.y, min_y, max_y))
# =============================================================================
# T-1153: rung selection (design doc §5) — the continuous zoom ladder's join
# point between "what granularity is legal to request" (a rung, a discrete
# set) and "what density the client actually wants" (world extent per canvas
# px, a continuous quantity that tracks the live zoom level).
# =============================================================================
## Rung-selection rule — REDESIGNED (live round 3 finding, superseding the
## original §5-literal `2x`-visual-tolerance-only reading): select the
## FINEST rung whose OWN single-window coverage ceiling (MAX_COVERAGE_M)
## still covers the current world extent. Walks RUNGS_FINEST_FIRST
## (Quarter, District, Region) and returns the first whose ceiling is `>=
## world_extent_m` — the coarser rungs are tried only once the finer ones
## genuinely cannot show the requested extent in a single window.
##
## **Why this replaces the original `2x`-visual-tolerance formula entirely**
## (not just patches its Region case, as an earlier version of this function
## did): the design doc §5 rule ("coarsest rung whose spacing <= 2*(E/C)")
## implicitly assumes every rung's single window CAN cover any extent the
## rule selects it for — true for an unbounded wire budget, false here.
## `_clamp_window_n_mirror_v2` (AtlasWindowRequest) — the SAME clamp the
## server itself enforces — caps every rung's single-window real-world
## coverage at a fixed maximum (`MAX_COVERAGE_M`, this file): Quarter
## 32,768 m, District 131,072 m, Region 13,107,200 m (per single Region
## window — bug B's progressive TILING composes several to cover more, a
## viewer-level concern this function doesn't need to know about). A rung
## selected for an extent BEYOND its own ceiling would have its `n` silently
## clamped server-side to something covering only a FRACTION of the
## viewport — visually a tiny box, AND (the actual live-round bug this
## redesign fixes) a clamped echo that no longer matches whatever `n` the
## viewer was still carrying from the rung it's leaving, permanently failing
## the staleness check in `_on_window_ready()`.
##
## **The `2x` visual-tolerance rule becomes REDUNDANT under this model, not
## contradicted by it** — verified numerically: at the exact zoom where
## Quarter's coverage ceiling (32,768 m) is reached, the `2x` threshold
## (`2*E/C`) works out to ~41 m, far finer than even Quarter's own 512 m
## spacing. This means by the time coverage RELEASES a rung, the visual
## tolerance would ALREADY prefer something finer than that rung offers —
## i.e. every rung this function selects is, by construction, at or past its
## own "as fine as it can usefully be" point. The visual-tolerance rule's
## fine-end guarantee (never show a coarser composite than the screen can
## resolve) is automatically satisfied by "select the finest rung whose
## coverage allows it" — there is no case where the coverage rule picks a
## rung the visual rule would have rejected as too coarse, because Quarter
## (the finest rung) is always the answer whenever ANY rung's visual
## tolerance alone would have mattered.
##
## `world_extent_m`/`canvas_px` are both callers'-choice-of-axis (the held
## window is always square, so either axis of the viewport/extent pair gives
## the same answer — the caller picks one, consistently). `canvas_px` is
## kept as a parameter (unused by the coverage rule itself) for signature
## stability with existing callers and because a future finer-than-Quarter
## rung (block/tile, D-226(d)-gated, out of scope here) would plausibly need
## it again.
##
## Returns the granularity_v2 string tag ("Quarter" | "District" | "Region").
static func select_rung(world_extent_m: float, _canvas_px: float) -> String:
for rung: String in RUNGS_FINEST_FIRST:
if world_extent_m <= float(MAX_COVERAGE_M[rung]):
return rung
return "Region" # extent exceeds even Region's own single-window ceiling -> still Region (tiling's job)
## The metre spacing a given granularity_v2 tag resolves to — the inverse
## lookup select_rung() itself doesn't need but callers computing "what world
## extent does holding N districts at this rung actually cover" do (the
## viewer's own extent-in-real-units header line, and the full-zoom-out
## predicate below).
static func spacing_for_rung(granularity_v2: String) -> float:
for rung: Dictionary in RUNG_TABLE:
if rung["granularity_v2"] == granularity_v2:
return float(rung["spacing_m"])
return DISTRICT_SPACING_M # unknown tag -> district, matching the server's "unknown -> District" posture
# =============================================================================
# T-1153: full-zoom-out reset (Jeroen's D-226 T-1143-rulings HARD condition —
# "a full zoom-out resets to the original canonical planetary frame and
# location", the ladder's top rest state, not a drifted pan state).
# =============================================================================
## True once the requested world extent (at the CURRENT zoom, before any
## further zoom-out) covers the full body — i.e. the player has zoomed out as
## far as the ladder goes and is looking at (at least) the whole equatorial
## circumference. This is the crisp "fully zoomed out" definition the ticket
## asks for: `world_extent_m >= circumference_m` at the fit-zoom floor, rather
## than a fuzzy "close to MIN_ZOOM" heuristic (MIN_ZOOM is a UI clamp
## constant, not a planetary-coverage fact — a small body could hit MIN_ZOOM
## while still showing less than the whole circumference, and a huge body
## could show full coverage before MIN_ZOOM is reached, depending on
## CELL_PIXEL_SIZE/held_n; extent-vs-circumference is the honest test either
## way).
##
## **Distinct from select_rung()'s own coverage ceiling** (District's
## `DISTRICT_WINDOW_MAX_N * DISTRICT_SPACING_M = 131,072 m`, a fixed,
## body-independent number) — this predicate's threshold is the actual
## body's full circumference, always far larger than 131,072 m for any real
## planet. The two compose in the expected order: extent crosses 131,072 m
## first (select_rung() already reports Region well before this predicate
## fires), and only once extent reaches the WHOLE circumference does the
## top-rest-state reset itself trigger. There is no conflict between the two
## thresholds, just two different questions ("which rung" vs. "are we at the
## very top").
static func is_fully_zoomed_out(world_extent_m: float, body_radius_km: float) -> bool:
if body_radius_km <= 0.0:
return false # no-radius (tiny test body) has no circumference concept — never auto-resets
var circumference_m: float = TAU * body_radius_km * 1000.0
return world_extent_m >= circumference_m
# =============================================================================
# T-1153: pure screen<->world math extracted from AtlasWindowViewer for
# testability (the project's stated preference — static funcs over Control
# instance methods wherever the math doesn't need the scene tree).
# =============================================================================
## The world extent (metres) currently displayed across the LARGER viewport
## dimension — the `E` half of the §5 rung-selection rule's `E/C`.
## `cell_pixel_size` is the caller's district-at-zoom-1.0 constant
## (AtlasWindowViewer.CELL_PIXEL_SIZE) — the composite's ON-SCREEN FOOTPRINT
## is ALWAYS `held_n * cell_pixel_size * view_zoom` px for `held_n * DISTRICT_M`
## metres of world, REGARDLESS of which rung is currently held (this is
## exactly the invariant AtlasWindowOverlay.cell_grid_side_for_window()'s doc
## establishes on the render side: `n` districts occupy a FIXED screen
## footprint; only the DERIVED CELL RESOLUTION packed into that footprint
## varies by rung). So the metres-per-screen-px sample density is a pure
## function of `view_zoom` — DISTRICT_SPACING_M / (cell_pixel_size *
## view_zoom) — with NO granularity_v2 parameter needed at all: the rung
## itself is the OUTPUT of this calculation (via select_rung()), not an
## input to it.
static func world_extent_m(cell_pixel_size: float, view_zoom: float, viewport: Vector2) -> float:
var canvas_px: float = cell_pixel_size * view_zoom
if canvas_px <= 0.0:
return 0.0
var screen_px: float = maxf(viewport.x, viewport.y)
return DISTRICT_SPACING_M / canvas_px * screen_px
## The DistrictPos the viewport's screen center currently maps to, in RAW
## absolute district space (un-wrapped, un-clamped — the caller canonicalizes
## the final value it actually stores/sends, matching
## canonicalize_district_center()'s own "canonicalize once, at the boundary"
## discipline). Shared by AtlasWindowViewer's pan-edge refetch
## (_maybe_refloat_window()) and rung-reselect refetch
## (_maybe_reselect_rung()) so both read the SAME screen-to-district formula
## rather than two copies that could drift.
static func screen_center_to_district(
viewport_size: Vector2,
view_offset: Vector2,
view_zoom: float,
cell_pixel_size: float,
held_center: Vector2i,
held_n: int
) -> Vector2i:
var screen_center: Vector2 = viewport_size * 0.5
var canvas_pt: Vector2 = (screen_center - view_offset) / view_zoom
var cell: Vector2 = canvas_pt / cell_pixel_size
var half: float = float(held_n) / 2.0
var abs_col: float = float(held_center.x) - half + cell.x
var abs_row: float = float(held_center.y) - half + cell.y
return Vector2i(roundi(abs_col), roundi(abs_row))
## Live round 4 fix: the exact INVERSE of screen_center_to_district()'s own
## district-space math, in CANVAS-LOCAL space (i.e. _canvas's own child
## coordinate system — BEFORE _view_offset/_view_zoom, which is what
## AtlasWindowOverlay._draw()/_draw_tile_mosaic() draw into, since the
## Node2D's position/scale already carries pan/zoom). Every held-window
## convention in this file agrees canvas-local `(0,0)` is absolute district
## `(held_center - held_n/2)` — single-window `_draw()`'s own
## `Rect2(0,0,extent,extent)` relies on this being true for `held_center` ==
## the window's own center. `_draw_tile_mosaic()`'s per-tile placement must
## use this SAME formula (with the VIEWER's `held_center`/`held_n`, not a
## tile's own center/TILE_N) to land in the same coordinate frame the
## fit/pan/zoom machinery already assumes — drawing tiles relative to
## absolute district (0,0) directly (the live-round-4 bug) silently
## disagreed with fit_window_view()'s own `[0, held_n)`-from-origin
## assumption whenever `held_n` (the WHOLE-BODY extent in tile mode) wasn't
## itself anchored the same way, pushing the entire mosaic off-canvas.
static func district_to_canvas_local(
district: Vector2, held_center: Vector2i, held_n: int, cell_pixel_size: float
) -> Vector2:
var half: float = float(held_n) / 2.0
var local_col: float = (district.x - (float(held_center.x) - half)) * cell_pixel_size
var local_row: float = (district.y - (float(held_center.y) - half)) * cell_pixel_size
return Vector2(local_col, local_row)
## Live round 5 fix (the tile-mosaic WRAP half of "the mosaic doesn't fully
## draw"): `compute_tile_grid()`'s tiles are CANONICAL columns (wrapped into
## `[0, cols)` — the correct, single-valued key for REQUESTS and cache
## coalescing), but a canonical column has infinitely many EQUIVALENT
## on-screen positions (`col`, `col - cols`, `col + cols`, ...), since
## longitude is periodic. `district_to_canvas_local()` is a pure LINEAR
## function with no wrap concept — fed a canonical column directly, it
## places the tile at exactly ONE of those wrap-images, which is only ever
## the visually-correct one by coincidence. Lendel's own repro: the tile
## whose pre-canonicalization center was -6400 canonicalizes to 12739
## (`-6400 mod 19139`) — correct for the request/cache key, but drawing at
## column 12739 directly places it canvas-local ~22308 (off-canvas RIGHT),
## when the tile's actual visible position (immediately west of the
## canonical origin) is at column -6400 (canvas-local ~3169, the LEFT
## third of the mosaic).
##
## The fix: before handing a tile's canonical column to
## `district_to_canvas_local()`, re-express it as whichever wrap-image
## (`canonical_col + k*cols` for integer `k`) is NEAREST `held_center.x` —
## the representative that's actually near the current view, matching how a
## real, non-tiling single-window pan already resolves the "which
## circumnavigation" question implicitly (screen_center_to_district()'s own
## RAW, un-wrapped output). `cols <= 0` (no-radius bodies, which never tile
## per compute_tile_grid()'s own doc) is a safe no-op passthrough — there is
## no periodicity to resolve.
static func nearest_wrap_image(canonical_col: int, held_center_col: int, cols: int) -> int:
if cols <= 0:
return canonical_col
var delta: int = posmod(canonical_col - held_center_col + cols / 2, cols) - cols / 2
return held_center_col + delta
## Live round 4 fix (the SECOND half of the "pitch black" repro, beyond
## district_to_canvas_local()'s tile-mosaic fix above): `_view_offset` is a
## PURE screen<->canvas-local transform, entirely independent of
## `held_center`/`held_n` — cursor-anchored zoom (`_zoom_at()`) never
## references them. But the single-window `_draw()` path draws the held
## composite at canvas-local `Rect2(0,0,extent,extent)`, which is ONLY the
## right place on screen if canvas-local (0,0) still equals
## `held_center - held_n/2` for the NEW rung. `_maybe_reselect_rung()`
## updates `held_center`/`held_n` to the new rung's values (a DIFFERENT
## `held_n` — Region's ~thousands vs. District's 64 vs. Quarter's 16) but
## never touched `_view_offset` to compensate — so canvas-local (0,0)
## silently stopped meaning `held_center - held_n/2` the instant `held_n`
## changed, and the composite (still drawn at local (0,0)) landed wherever
## the STALE offset happened to put it — off-canvas by tens or hundreds of
## thousands of px for a Region-to-District/Quarter crossing (round 4's
## repro), same root shape as the tile-mosaic bug, just on the "one held
## window" side of the split instead of the "many tiles" side.
##
## This is the exact INVERSE construction: given the SAME screen point that
## used to map to `old_local` must now map to canvas-local
## `new_held_n/2 * cell_pixel_size` (i.e. new_held_center's own position
## under the NEW window's `[0, new_held_n)` span), solve for the
## `view_offset` that makes `screen_point == new_local * view_zoom +
## view_offset` true. Pan-edge refetch (`_maybe_refloat_window()`) never
## needed this because it never changes `held_n` — only rung crossings do.
static func recompute_offset_for_held_n_change(
screen_point: Vector2, view_zoom: float, new_held_n: int, cell_pixel_size: float
) -> Vector2:
var new_local: Vector2 = Vector2.ONE * (float(new_held_n) * 0.5 * cell_pixel_size)
return screen_point - new_local * view_zoom
# =============================================================================
# T-1145 item 2 (moved here T-1153 for file-length/testability): WASD/
# arrow-key held-pan direction + edge-scroll suppression/direction — pure
# functions of explicit inputs, no Control/scene-tree dependency.
# =============================================================================
## WASD + arrow keys, read via Input.is_key_pressed() on the PHYSICAL keycode
## (not an InputMap action): W/S/A/D on this project's global InputMap are
## already bound to move_north/move_south/move_east/move_west (gameplay
## movement, D-054 mouse-relative facing) — reusing those actions here would
## make holding W simultaneously pan this map AND queue a gameplay move
## command server-side the moment this implant screen closes back to
## gameplay (InputMapper polls Input.is_action_pressed() unconditionally,
## with no implant-occlusion guard — a genuine pre-existing gap outside this
## ticket's scope, not introduced here). Reading the raw physical keycode
## instead of the shared action name means this screen's WASD use is fully
## independent of whatever the gameplay action happens to be bound to — same
## key, two UNRELATED consumers, neither needs to know about the other.
## Arrow keys have no InputMap action bound at all, so they're conflict-free
## either way. Returns a raw (non-normalized) direction — the caller
## normalizes once after adding the edge-scroll contribution, so N+E doesn't
## move faster than N alone. Reads the global `Input` singleton directly (not
## injected) — this is the one function in this file that isn't a pure
## function of its arguments, kept here anyway to sit beside its two siblings
## below rather than splitting the WASD/edge-scroll trio across two files.
static func held_pan_direction() -> Vector2:
var direction := Vector2.ZERO
if Input.is_key_pressed(KEY_W) or Input.is_key_pressed(KEY_UP):
direction.y -= 1.0
if Input.is_key_pressed(KEY_S) or Input.is_key_pressed(KEY_DOWN):
direction.y += 1.0
if Input.is_key_pressed(KEY_A) or Input.is_key_pressed(KEY_LEFT):
direction.x -= 1.0
if Input.is_key_pressed(KEY_D) or Input.is_key_pressed(KEY_RIGHT):
direction.x += 1.0
return direction
## T-1145 item 2: edge-scroll is suppressed (a) while the cursor is over UI
## (`is_over_ui` — the caller's own _is_over_ui() result, passed in rather
## than called from here since "what counts as UI" is viewer-specific) and
## (b) while the application window itself lacks OS focus (`app_has_focus` —
## otherwise a background window with the cursor left resting near its edge
## from a previous session would silently pan while the player is doing
## something else entirely; Godot's NOTIFICATION_APPLICATION_FOCUS_OUT/IN
## make this directly detectable, the caller's own _notification() wires it).
static func is_cursor_edge_scrolling(
app_has_focus: bool,
is_over_ui: bool,
viewport_size: Vector2,
mouse_pos: Vector2,
edge_margin_px: float
) -> bool:
if not app_has_focus:
return false
if is_over_ui:
return false
if viewport_size.x <= 0.0 or viewport_size.y <= 0.0:
return false
return (
mouse_pos.x >= 0.0
and mouse_pos.y >= 0.0
and mouse_pos.x <= viewport_size.x
and mouse_pos.y <= viewport_size.y
and (
mouse_pos.x < edge_margin_px
or mouse_pos.y < edge_margin_px
or mouse_pos.x > viewport_size.x - edge_margin_px
or mouse_pos.y > viewport_size.y - edge_margin_px
)
)
## Direction toward whichever edge(s) the cursor is near — same shape as
## held_pan_direction() (a raw, un-normalized Vector2 the caller combines and
## normalizes once).
static func edge_scroll_direction(
viewport_size: Vector2, mouse_pos: Vector2, edge_margin_px: float
) -> Vector2:
var direction := Vector2.ZERO
if mouse_pos.x < edge_margin_px:
direction.x -= 1.0
elif mouse_pos.x > viewport_size.x - edge_margin_px:
direction.x += 1.0
if mouse_pos.y < edge_margin_px:
direction.y -= 1.0
elif mouse_pos.y > viewport_size.y - edge_margin_px:
direction.y += 1.0
return direction
# =============================================================================
# T-1153, live round 3 (Jeroen's ruling, design doc §4): the orbital REST
# STATE must TILE — a single wire-capped Region window (MAX_COVERAGE_M["Region"]
# = 13,107,200 m) covers only a fraction of a real body's circumference
# (Lendel: 39,197,023 m — a single window is ~a third of the body). The top
# rest state composes MULTIPLE Region windows ("progressive capped-density
# TILING", design doc §4) into a mosaic under ONE view transform.
# =============================================================================
## Compute the tile-set grid for the orbital rest state: the minimal set of
## Region-granularity window CENTERS (each `TILE_N` districts wide) whose
## union covers the WHOLE body — columns wrap (canonicalize_district_center()'s
## own east-west periodicity), rows clamp at the poles. Returns an Array of
## Vector2i tile centers, ALREADY CANONICALIZED (duplicates from pole-row
## clamping or (degenerately) column-wrap collisions are DEDUPED — a tiny
## body where multiple nominal tile rows clamp to the identical pole-adjacent
## row, or multiple nominal tile columns wrap to the identical column, must
## not request/draw the same tile twice).
##
## Grid layout: `cols_tiles = ceil(cols / TILE_N)` tiles span the full
## circumference (evenly spaced, centered on column 0 — the canonical
## origin); `rows_tiles = ceil(2*rows_half / TILE_N)` tiles span pole to
## pole (centered on row 0). Each tile's PRE-CANONICALIZATION center is
## `(tile_index - (tile_count-1)/2) * TILE_N` along its axis — symmetric
## around the canonical origin, matching enter_orbital()'s own "canonical
## origin = (0,0)" convention (AtlasDescendGeometry's doc) so the tile set's
## own center-of-mass lands exactly on the canonical frame, not offset from
## it.
##
## No-radius bodies (tiny test bodies, `body_radius_km <= 0`) return a
## single tile at (0,0) — matching enter_orbital()'s own no-radius fallback
## disposition (no circumference/tiling concept for a body with no radius).
static func compute_tile_grid(body_radius_km: float) -> Array:
if body_radius_km <= 0.0:
return [Vector2i.ZERO]
var extent: Dictionary = AtlasDescendGeometryRef.district_extent(body_radius_km)
var cols: int = int(extent["cols"])
var rows_half: int = int(extent["rows_half"])
var rows_total: int = rows_half * 2
var cols_tiles: int = maxi(1, ceili(float(cols) / float(TILE_N)))
var rows_tiles: int = maxi(1, ceili(float(rows_total) / float(TILE_N)))
var col_centers: Array = []
for tx in range(cols_tiles):
var raw_col: int = roundi((float(tx) - (float(cols_tiles - 1) * 0.5)) * float(TILE_N))
col_centers.append(raw_col)
var row_centers: Array = []
for ty in range(rows_tiles):
var raw_row: int = roundi((float(ty) - (float(rows_tiles - 1) * 0.5)) * float(TILE_N))
row_centers.append(raw_row)
# Dedup via a Dictionary keyed on the CANONICALIZED (col, row) pair —
# Godot Dictionary keys compare Vector2i by value, so this is a proper
# set. Insertion order is preserved (Godot Dictionaries are
# order-preserving), giving a deterministic tile ORDER too — the same
# grid always requests/draws in the same sequence, useful for progressive
# arrival to read as a stable left-to-right, top-to-bottom fill rather
# than an unpredictable one.
var seen: Dictionary = {}
var tiles: Array = []
for raw_col: int in col_centers:
for raw_row: int in row_centers:
var canonical: Vector2i = AtlasDescendGeometryRef.canonicalize_district_center(
Vector2i(raw_col, raw_row), body_radius_km
)
if not seen.has(canonical):
seen[canonical] = true
tiles.append(canonical)
return tiles
# =============================================================================
# T-1153: screen header chrome (D-169/D-170) — pure string-building, moved
# here from atlas_window_viewer.gd for file-length (the viewer's own
# `_refresh_screen_header()`/`_location_label()` stay as thin wrappers, since
# both are directly tested by name).
# =============================================================================
## Body name + coordinate label — T-1142: shows the body's proper name
## (falling back to body_id) alongside the held district center, so the
## header never reads as bare "district (col, row)" with no indication of
## WHICH body the player is looking at.
static func location_label(body_display_name: String, held_center: Vector2i) -> String:
return "%s — (%d, %d)" % [body_display_name, held_center.x, held_center.y]
## D-169/D-170 implant chrome (§5): {title, subtitle} for the screen header.
## The subtitle's extent (`held_n` districts) is rung-INVARIANT (n is always
## district extent — see AtlasWindowOverlay.cell_grid_side_for_window()'s
## doc), but the km/cell reading reflects the HELD rung's actual spacing
## (2.048 km District, 0.512 km Quarter, 204.8 km Region) — the "continuous
## metres-per-pixel/extent readout" design doc §6 calls for in place of a
## discrete "you are now in Quarter Mode" label (Jeroen's "no mode
## transition" ruling): the number itself communicates the rung.
static func screen_header_content(
body_display_name: String,
held_center: Vector2i,
held_n: int,
held_granularity_v2: String,
district_m: float
) -> Dictionary:
var label: String = location_label(body_display_name, held_center)
var extent_km: float = float(held_n) * district_m / 1000.0
var spacing_km: float = spacing_for_rung(held_granularity_v2) / 1000.0
var subtitle: String = "%.1f x %.1f km · %.3f km/cell" % [extent_km, extent_km, spacing_km]
return {"title": "REGIONAL — %s" % label.to_upper(), "subtitle": subtitle}
## Cold-start dossier: the baseline position draw_string() needs to render
## `text` horizontally AND vertically centered inside `viewport_size` — pure
## geometry, split out of AtlasWindowViewer._draw_deriving_terrain_label()
## for file-length (gdlint max-file-lines), not a different concern. Callers
## pass HORIZONTAL_ALIGNMENT_CENTER to draw_string() themselves (that part
## isn't pure — it needs the real Font instance) — this only computes the Y
## baseline offset and the X center point draw_string()'s own centering
## then works from.
static func centered_label_baseline(viewport_size: Vector2, text_size: Vector2) -> Vector2:
var center: Vector2 = viewport_size * 0.5
return center - text_size * 0.5 + Vector2(0.0, text_size.y * 0.5)
## T-1172 round 2 (coordinator's "reconsider the split" ask): the SHARED
## cell-index formula both AtlasWindowOverlay's terrain painter (which builds
## the drawn `grid_side x grid_side` per-cell texture — `i = row * grid_side
## + col`, `img.set_pixel(col, row, ...)`) and AtlasWindowWaterClip's clip
## predicate (which must read the EXACT SAME cell for a given position, or
## the clip silently disagrees with what's actually painted) both need. The
## live trace that closed T-1172 round 2's investigation PROVED this formula
## itself was never the bug (painter and clip independently computed the
## identical col/row/idx for the same query throughout) — the actual bug was
## in the WRAP resolution one layer up (resolve_morphology_zone()'s own doc)
## — but factoring the index math into ONE shared function here, rather than
## two independently-maintained copies (atlas_window_overlay.gd's inline
## `row * grid_side + col` vs. the old duplicate in
## atlas_window_water_clip.gd), removes the STRUCTURAL risk of a future
## divergence in exactly the way the coordinator flagged as the general
## danger class ("stub-and-code agreeing on the wrong convention" — here,
## PAINTER-and-clip could drift the same way without a shared source).
## `local_x`/`local_y` are DISTRICT-SPACE offsets from the window's own
## top-left corner (`center - n/2`), in `[0, n)` — the SAME quantity both
## call sites already compute before this function is reached.
static func cell_index_for_local_offset(
local_x: float, local_y: float, n: int, grid_side: int
) -> Vector2i:
if n <= 0 or grid_side <= 0:
return Vector2i(-1, -1)
var col: int = clampi(int(floor(local_x / float(n) * float(grid_side))), 0, grid_side - 1)
var row: int = clampi(int(floor(local_y / float(n) * float(grid_side))), 0, grid_side - 1)
return Vector2i(col, row)
@@ -1,685 +0,0 @@
extends RefCounted
## Nature-overlay (river/basin/attractor) pure geometry + per-rung policy —
## split out of atlas_window_geometry.gd (T-1170, that file was at 954/1000
## gdlint max-file-lines when this batch started) exactly the same way
## test_atlas_window_geometry_nature.gd was already split from
## test_atlas_window_geometry.gd — one file, one concern, room to grow. Every
## symbol below moved VERBATIM from atlas_window_geometry.gd; no behavior
## change in this split itself. atlas_window_nature_overlay.gd is the only
## runtime consumer (verified: grep across client/ before the move) and now
## preloads THIS file instead.
##
## Contains:
## - T-1156 wave 1 whole-body Layer-1 pixel-space -> canvas-local mapping
## (layer1_pixel_to_world_m/world_m_to_district/layer1_pixel_to_canvas_local)
## - T-1156 wave 1 per-rung skeleton visibility/styling policy (RIVER_CLASS_*,
## CONFLUENCES/MOUTHS/BASINS/ATTRACTORS_VISIBLE_BY_RUNG, dot/ring/attractor
## size consts) — RENAMED this batch (T-1170 Ruling 5c, see below) from
## RIVER_CLASS_VISIBLE_BY_RUNG to SKELETON_CLASS_VISIBLE_BY_RUNG.
## - zoom_compensated_size() — the screen-space marker-size zoom-compensation
## fix (coordinator live-eyeball finding, 2026-07-23).
##
## atlas_window_geometry.gd retains cell_index_for_local_offset() (T-1172
## round 2) rather than moving it here — that function is shared with
## AtlasWindowOverlay's terrain painter (a non-nature consumer), so it stays
## on the base file both files already depend on, avoiding a nature-file ->
## base-file dependency for a symbol the base file's own painter needs too.
const AtlasDescendGeometryRef := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd")
## atlas_window_geometry.gd never depends on this file (verified: no preload
## of atlas_window_geometry_nature.gd anywhere in that file) — so preloading
## it back here is safe, no circular dependency, matching the pattern
## AtlasWindowOverlay/AtlasWindowWaterClip already use for
## AtlasWindowGeometryRef.
const AtlasWindowGeometryRef := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
## D-243 district spacing, metres/district — this file's own copy of
## AtlasWindowGeometry.DISTRICT_SPACING_M (duplicated, not preloaded-and-read,
## matching this cluster's existing "each file owns its own reading of a
## small pure constant rather than force a dependency" precedent —
## atlas_overlay_colors.gd's header doc states this explicitly; the same
## rationale that kept atlas_window_water_clip.gd's cell_grid_side_for_window()
## a deliberate duplicate rather than a shared call applies here). MUST stay
## numerically identical to the base file's constant — both ultimately trace
## to D-243's 2,048 m district spacing, which is locked project vocabulary,
## not a value expected to drift.
const DISTRICT_SPACING_M: float = 2048.0
# =============================================================================
# T-1156 wave 1 / T-1170: per-rung nature-overlay visibility/styling policy
# (Araminta's presentation ruling, 2026-07-23 — supersedes Tyre's provisional
# add-detail-as-you-descend mapping the ticket brief originally carried).
#
# T-1170 Ruling 5c (Tyre, 2026-07-23) — THE REVISIT, split on the carrier
# axis: the single RIVER_CLASS_VISIBLE_BY_RUNG table is replaced by TWO
# tables, one per presentation surface —
# - SKELETON_CLASS_VISIBLE_BY_RUNG: the Region+ whole-body skeleton-chord
# path (Ruling 5a) — unchanged posture from wave 1, Region shows every
# class.
# - COURSE_CLASS_VISIBLE_BY_RUNG: the District/Quarter windowed course-
# polyline path (Ruling 5b) — THIS is where "Quarter rivers return"
# (the pre-announced wave-1 fade-down revisit executes): District shows
# trunk+tributary, Quarter shows all three classes.
# Companion per-class width/opacity tables (COURSE_CLASS_WIDTH_PX/
# COURSE_CLASS_OPACITY) carry FUNCTIONAL DEFAULTS per the ruling brief
# (trunk widest ~2.2px, tributary ~1.4px, stream ~0.9px, screen-space via the
# existing zoom-compensation discipline) — Araminta's forthcoming presentation
# ruling edits THESE TABLES AND ONLY THESE TABLES, same single-revisit-point
# discipline wave 1 established for RIVER_CLASS_VISIBLE_BY_RUNG itself.
# =============================================================================
## River class ids — mirrors server/src/atlas/body_world_state.rs
## RiverNetwork.river_class's own doc exactly (0=stream, 1=tributary,
## 2=trunk). A `river_class` array shorter than `river_cells` (pre-T-1156
## payload, or the graceful-fallback empty-array case) has no per-cell class
## to read — RIVER_CLASS_FALLBACK is what a missing entry resolves to: TRUNK,
## so an old/absent river_class array still shows something at every rung
## rather than silently vanishing (Dudley's `#[serde(default)]` empty-array
## contract makes "index out of range" the normal case for a pre-T-1156
## response, not an edge case to special-case away).
const RIVER_CLASS_STREAM: int = 0
const RIVER_CLASS_TRIBUTARY: int = 1
const RIVER_CLASS_TRUNK: int = 2
const RIVER_CLASS_FALLBACK: int = RIVER_CLASS_TRUNK
# =============================================================================
# T-1170 Ruling 2a-2d/5a: river_downstream D8 pointer decode — the wire
# convention `RiverNetwork.river_downstream` (Vec<u8>, index-aligned with
# river_cells) encodes per river cell: a DIRECTION 0-7 into an adjacent D8
# neighbor, or a SENTINEL >= RIVER_DOWNSTREAM_SENTINEL_BASE marking a chain
# end (MOUTH/EDGE_DRAIN/reserved-TERMINAL). CONFIRMED against Dudley's A1
# (server/src/atlas/drainage.rs:35-44, landed 0fea69feb, relayed by the
# coordinator) — these are the REAL shipped values, final until/unless the
# server's own encoding changes, in which case this is the one place to
# repoint.
# =============================================================================
## Sentinel base — any river_downstream value >= this is a chain-end
## sentinel, not a direction. Direction values are 0-7 (8 real D8 neighbors);
## sentinels start immediately above at 8.
const RIVER_DOWNSTREAM_SENTINEL_BASE: int = 8
const RIVER_DOWNSTREAM_MOUTH: int = 8
const RIVER_DOWNSTREAM_EDGE_DRAIN: int = 9
## TERMINAL is reserved/unused in round 1 (Ruling 2c/7b — future endorheic
## basin support) — this client never expects to see it on real data yet, but
## decodes it identically to EDGE_DRAIN (chain end, no ring) rather than
## treating an unrecognized-but-in-sentinel-range value as an error, so a
## future server enabling TERMINAL needs no client change to degrade
## gracefully (it would just draw as an unmarked chain end until a future
## ticket gives it its own ring treatment, exactly EDGE_DRAIN's own current
## disposition).
const RIVER_DOWNSTREAM_TERMINAL: int = 10
## D8 direction index (0-7) -> (row_delta, col_delta), CONFIRMED against
## drainage.rs:35-44's own fdir table order (not assumed/guessed — the
## coordinator relayed this explicitly from Dudley's A1 source): row
## increases SOUTH (matching layer1_pixel_to_world_m()'s own "row 0 = north
## pole" convention, confirmed the same convention on both sides of this
## mapping), col increases EAST and WRAPS at the antimeridian (handled by the
## caller's existing nearest-wrap-image discipline, same as every other
## column value flowing through this file — this table itself has no wrap
## concept, it is pure grid-adjacency).
## 0 = N (-1, 0) 4 = NE (-1, 1)
## 1 = S ( 1, 0) 5 = NW (-1, -1)
## 2 = E ( 0, 1) 6 = SE ( 1, 1)
## 3 = W ( 0, -1) 7 = SW ( 1, -1)
const D8_DIRECTION_DELTAS: Array = [
Vector2i(-1, 0), # 0 N
Vector2i(1, 0), # 1 S
Vector2i(0, 1), # 2 E
Vector2i(0, -1), # 3 W
Vector2i(-1, 1), # 4 NE
Vector2i(-1, -1), # 5 NW
Vector2i(1, 1), # 6 SE
Vector2i(1, -1), # 7 SW
]
# =============================================================================
# T-1170 Ruling 3h/5b: RiverCourse.terminus wire vocabulary — the
# CourseTerminus enum's variant NAMES as they arrive over msgpack (bare
# strings, the SAME "unit variant -> string tag" convention granularity_v2
# already uses on this same wire — see AtlasMapProtocol's own doc). `None` on
# the Rust side (a mid-window course that neither reaches a real mouth nor
# the window edge — the ordinary "ends because the chord's amplitude taper
# reached zero at a confluence/headwater anchor cell inside this window"
## case) decodes to the bare string "None" per rmp_serde's unit-variant
## convention — NOT GDScript `null`. Callers must compare against the STRING
## constant below, never `== null`.
# =============================================================================
const COURSE_TERMINUS_NONE: String = "None"
const COURSE_TERMINUS_MOUTH: String = "Mouth"
const COURSE_TERMINUS_EDGE_DRAIN: String = "EdgeDrain"
const COURSE_TERMINUS_CONTINUES_BEYOND_WINDOW: String = "ContinuesBeyondWindow"
## Region+ SKELETON path (Ruling 5a) — the whole-body chord-chain draw, built
## from river_downstream. Region shows every class (the full skeleton) — this
## table's posture is UNCHANGED from wave 1's original
## RIVER_CLASS_VISIBLE_BY_RUNG (renamed, not re-tuned). District/Quarter keys
## are retained (both empty) purely so a caller that queries this table by an
## unexpected rung tag gets the same documented "nothing visible" answer wave
## 1 shipped, rather than a KeyError — the SKELETON path itself is only ever
## drawn at Region in practice (District/Quarter draw courses, the OTHER
## table, per Ruling 5b).
const SKELETON_CLASS_VISIBLE_BY_RUNG: Dictionary = {
"Region": [RIVER_CLASS_STREAM, RIVER_CLASS_TRIBUTARY, RIVER_CLASS_TRUNK],
"District": [],
"Quarter": [],
}
## District/Quarter COURSE path (Ruling 5b/5c) — the windowed polyline draw,
## built from DistrictWindowLayer.courses. District: trunk+tributary (streams
## stay off at District — the ruling's own example enumeration). Quarter:
## ALL THREE classes — "Quarter rivers return", the pre-announced wave-1
## fade-down revisit executing here. Region is not a key here at all (Region
## never draws courses — it draws the skeleton chord chain, the OTHER table)
## — a caller must not query this table at Region; river_class_visible_at_rung()
## style readers for this table live on this file too and fall back safely
## for an unrecognized tag (see course_class_visible_at_rung()'s own doc).
const COURSE_CLASS_VISIBLE_BY_RUNG: Dictionary = {
"District": [RIVER_CLASS_TRIBUTARY, RIVER_CLASS_TRUNK],
"Quarter": [RIVER_CLASS_STREAM, RIVER_CLASS_TRIBUTARY, RIVER_CLASS_TRUNK],
}
## Per-rung feature-group toggles beyond river-cell class filtering — whether
## confluences/mouths/basins/attractors draw at all at a given rung (each
## still additionally gated by its own overlay-bar toggle, RVR/BAS/ATR, where
## applicable — this table is the RUNG gate, the overlay bar is the PLAYER
## gate, both must pass). Mouths get the one rung-based exception in the whole
## table: District keeps them at full Region styling/opacity (a mouth is
## always a landmark, per the ruling) while every other District river feature
## is suppressed or de-emphasized.
const CONFLUENCES_VISIBLE_BY_RUNG: Dictionary = {"Region": true, "District": false, "Quarter": false}
const MOUTHS_VISIBLE_BY_RUNG: Dictionary = {"Region": true, "District": true, "Quarter": false}
const BASINS_VISIBLE_BY_RUNG: Dictionary = {"Region": true, "District": false, "Quarter": false}
const ATTRACTORS_VISIBLE_BY_RUNG: Dictionary = {"Region": true, "District": false, "Quarter": false}
## Per-rung river dot styling (screen-space px, at zoom=1.0 — the same
## "canvas-local px" domain every other drawn feature in this cluster already
## uses, scaled by the caller's own view zoom like everything else in
## `_canvas`). District trunk dots are smaller AND drawn at reduced opacity
## (80% — raised from the ruling's initial 60% in Araminta's PR #195 capture
## review: at 1.6px/60% the dot was "essentially invisible without knowing
## where to look", underselling the 'a major river crosses near here' intent;
## 2.0px/80% keeps the fade-down ladder vs Region's 2.2px/100% without
## reading as accidentally-erased) — the "fade down" the ruling describes;
## Region dots are full-strength opacity (alpha baked into the reused
## COLOR_GEN_RIVER/COLOR_GEN_MOUTH constants themselves, alpha 1.0).
##
## T-1170: these RIVER_DOT_* consts now describe the Region SKELETON path
## ONLY (Ruling 5a's chord-chain draw reuses the same per-class radii the old
## dot-scatter used — chords are drawn at these widths, not a new table).
## RIVER_DOT_RADIUS_DISTRICT_TRUNK/RIVER_DOT_OPACITY_DISTRICT_TRUNK are DEAD
## at District now that District draws courses (Ruling 5b/3g retires the
## District dot-scatter entirely) — left in place, unread by any T-1170 draw
## path, rather than deleted mid-batch: B3 (course polyline drawing) is the
## change that stops calling them; deleting here would be a premature edit to
## a still-referenced-by-wave-1-code constant ahead of that landing.
const RIVER_DOT_RADIUS_BY_CLASS_REGION: Dictionary = {
RIVER_CLASS_STREAM: 0.9,
RIVER_CLASS_TRIBUTARY: 1.4,
RIVER_CLASS_TRUNK: 2.2,
}
const RIVER_CONFLUENCE_RADIUS_REGION: float = 3.5
const RIVER_DOT_RADIUS_DISTRICT_TRUNK: float = 2.0
const RIVER_DOT_OPACITY_DISTRICT_TRUNK: float = 0.8
## T-1170 Ruling 5c: course polyline per-class width/opacity, District/Quarter
## COURSE path companion tables to COURSE_CLASS_VISIBLE_BY_RUNG above.
## FUNCTIONAL DEFAULTS ONLY (the ruling's own numbers) — Araminta's
## forthcoming presentation ruling edits these two tables and only these two
## tables, same discipline as every other single-revisit-point table in this
## file. Widths are screen-space px at zoom=1.0, routed through
## zoom_compensated_size()/the caller's `_zs()` wrapper before reaching
## draw_polyline() exactly like every other marker size in this cluster (PR
## #195's stroke-width miss is the standing regression class this discipline
## exists to prevent — see zoom_compensated_size()'s own doc). Opacities are
## plain [0,1] alpha multipliers on COLOR_GEN_RIVER, no zoom involvement.
## Trunk widest / stream thinnest, matching the Region skeleton's own
## per-class radius ordering (RIVER_DOT_RADIUS_BY_CLASS_REGION) so the visual
## "trunk is the biggest river" read is consistent whether the player is
## looking at the Region chord chain or a District/Quarter course polyline.
const COURSE_CLASS_WIDTH_PX: Dictionary = {
RIVER_CLASS_STREAM: 0.9,
RIVER_CLASS_TRIBUTARY: 1.4,
RIVER_CLASS_TRUNK: 2.2,
}
const COURSE_CLASS_OPACITY: Dictionary = {
RIVER_CLASS_STREAM: 0.8,
RIVER_CLASS_TRIBUTARY: 0.9,
RIVER_CLASS_TRUNK: 1.0,
}
## Mouth double-ring geometry (Region AND District — mouths never de-emphasize,
## per the ruling) — verbatim from the retired atlas_marker_overlay.gd
## _draw_gen_rivers() (:537-539), reused exactly, not re-tuned. T-1170: also
## the mouth-ring geometry for REAL course termini (Ruling 5b/3e) — one
## geometry, both presentation surfaces (skeleton chord ends at Region,
## course polyline ends at District/Quarter).
const MOUTH_RING_RADIUS: float = 5.0
const MOUTH_HALO_RADIUS: float = 8.0
const MOUTH_HALO_ALPHA: float = 0.30
## T-1170 live round (2026-07-23, coordinator's mouth-ring blob finding):
## the minimum radius/stroke RATIO a draw_arc() ring needs to render hollow
## rather than degenerate into a solid blob — see
## zoom_compensated_ring_radius()'s own doc for the full A/B bracket
## evidence (radius=1x stroke -> blob, 1.5x -> hollow, floor set at 2x with
## margin over the observed transition).
const RING_RADIUS_STROKE_MULTIPLIER: float = 2.0
## Attractor minimum-strength gate — verbatim from the retired
## atlas_marker_overlay.gd GEN_ATTRACTOR_MIN_STRENGTH (:44). Region-only per
## the ruling (ATTRACTORS_VISIBLE_BY_RUNG), wave 1 has no attractor rendering
## at any other rung to gate.
const ATTRACTOR_MIN_STRENGTH: float = 0.15
# =============================================================================
# T-1156 wave 1: whole-body Layer-1 (river/basin/attractor) pixel-space ->
# canvas-local mapping for the zoom ladder — the nature-overlay counterpart to
# the district/canvas machinery in atlas_window_geometry.gd. Layer-1's
# `river_network`/`drainage_basins`/`attractors` positions are (row, col)
# heightmap-pixel coordinates in a `grid_w`(cols) x `grid_h`(rows) working
# grid (Rust `Layer1Output.grid_w/grid_h` = `BodyHeightmap.width/height` = the
# SAME `TerrainAnalysis.w/h` river/attractor extraction ran against —
# server/src/atlas/layer1.rs, features.rs `TerrainAnalysis::analyze`). This is
# NOT the atlas_marker_overlay.gd `_gen_pos()` texture-fraction mapping (that
# maps onto a DISPLAYED heightmap texture on the retired planetary screen) —
# the ladder has no resident heightmap texture at all, so pixel positions must
# go all the way to WORLD METRES -> DISTRICT space -> canvas-local, the same
# frame AtlasWindowGeometry.district_to_canvas_local() already establishes for
# every other drawn feature on this screen.
# =============================================================================
## Heightmap pixel (row, col) -> absolute world metres (wx east, wy south),
## mirroring server/src/atlas/district_profile.rs's `pixel_to_world_m()`
## EXACTLY (verified against that function's source, not assumed): longitude
## WRAPS and is addressed by the plain column fraction (`col / grid_w`) against
## the full circumference — column 0 sits at world/longitude 0, no -0.5
## centering unlike latitude. Latitude CLAMPS at the poles and is addressed by
## `row / (grid_h - 1) - 0.5`, i.e. row 0 is exactly the pole (lat_frac -0.5 =
## north pole = wy negative-most) and row (grid_h - 1) is exactly the opposite
## pole (lat_frac +0.5 = south pole = wy positive-most) — the SAME "row
## increases southward" convention AtlasDescendGeometry.district_pos_at()
## already assumes for its own (inverse-direction) pixel<->district mapping,
## confirmed here to be the same convention layer1's grid uses, not a
## different one that happens to share variable names.
##
## No-radius bodies (body_radius_km <= 0, tiny test bodies): 1 heightmap pixel
## = 1 district-spacing metre, matching pixel_to_world_m()'s own no-radius
## fallback (`px * scale::DISTRICT_M`) and district_pos_at()'s no-radius
## branch on the other side of this mapping. Uses this file's own
## DISTRICT_SPACING_M (the same 2,048 m/district constant — see that const's
## own doc for why it's a deliberate duplicate, not a preload-and-read).
static func layer1_pixel_to_world_m(
row: float, col: float, grid_w: float, grid_h: float, body_radius_km: float
) -> Vector2:
if grid_w <= 0.0 or grid_h <= 0.0:
return Vector2.ZERO
if body_radius_km <= 0.0:
return Vector2(col * DISTRICT_SPACING_M, row * DISTRICT_SPACING_M)
var circumference_m: float = TAU * body_radius_km * 1000.0
var meridian_m: float = PI * body_radius_km * 1000.0
var wx: float = (col / grid_w) * circumference_m
var lat_frac: float = (row / (grid_h - 1.0) - 0.5) if grid_h > 1.0 else 0.0
var wy: float = lat_frac * meridian_m
return Vector2(wx, wy)
## World metres -> fractional DistrictPos (NOT rounded to an integer district
## — a river dot's true position is sub-district-precise even though the
## window grid itself is district-granular; rounding here would visibly snap
## every river pixel onto a district lattice). DISTRICT_SPACING_M is this
## file's own existing constant (2,048 m/district, D-243) — one division, no
## re-derivation.
static func world_m_to_district(world_m: Vector2) -> Vector2:
return world_m / DISTRICT_SPACING_M
## The full pixel(row,col) -> canvas-local composition a nature-overlay draw
## call needs in one step: heightmap pixel -> world metres -> fractional
## district -> canvas-local (via AtlasWindowGeometry.district_to_canvas_local(),
## reused verbatim so a river dot lands in exactly the same coordinate frame
## every other drawn feature on this screen already agrees on — pan/zoom/rung
## crossings all move the SAME transform under everything drawn into
## `_canvas`). Wrap resolution (AtlasWindowGeometry.nearest_wrap_image()) is
## the CALLER's job, same split the tile mosaic draw path already uses — this
## function's `district` output is the RAW (un-wrapped) fractional position; a
## caller iterating river cells against a specific held window picks the
## nearest wrap-image of the COLUMN only (rows never wrap, matching every
## other wrap-aware caller in this cluster).
static func layer1_pixel_to_canvas_local(
row: float,
col: float,
grid_w: float,
grid_h: float,
body_radius_km: float,
held_center: Vector2i,
held_n: int,
cell_pixel_size: float
) -> Vector2:
var world_m: Vector2 = layer1_pixel_to_world_m(row, col, grid_w, grid_h, body_radius_km)
var district: Vector2 = world_m_to_district(world_m)
return AtlasWindowGeometryRef.district_to_canvas_local(district, held_center, held_n, cell_pixel_size)
## T-1170 Ruling 5b/3h: RiverCourse.points are already WORLD METRES on the
## wire (unlike the skeleton path's heightmap-pixel `river_cells` — see
## Ruling 3h's wire shape doc) — one fewer conversion step than
## layer1_pixel_to_canvas_local() above: world metres -> fractional district
## (world_m_to_district(), reused verbatim) -> canvas-local
## (AtlasWindowGeometry.district_to_canvas_local(), same shared transform
## every other drawn feature on this screen uses). No pixel-grid/body-radius
## step at all — courses have no heightmap-pixel domain to convert out of.
static func world_m_to_canvas_local(
world_m: Vector2, held_center: Vector2i, held_n: int, cell_pixel_size: float
) -> Vector2:
var district: Vector2 = world_m_to_district(world_m)
return AtlasWindowGeometryRef.district_to_canvas_local(district, held_center, held_n, cell_pixel_size)
# =============================================================================
# T-1170 Ruling 2a-2d/5a: river_downstream D8 pointer decode.
# =============================================================================
## Decode one river cell's `river_downstream` wire value into its downstream
## neighbor's (row, col) heightmap-pixel position, or `null` if the value is
## a chain-end sentinel (MOUTH/EDGE_DRAIN/TERMINAL) or an out-of-range/
## malformed direction. `row`/`col` are the UPSTREAM cell's own pixel
## position (float, matching this file's own row/col domain everywhere
## else); the return value (when non-null) is a Vector2 in that SAME
## (row, col) pixel domain — NOT yet converted to world metres/district/
## canvas-local, that conversion is the caller's job via the usual
## layer1_pixel_to_world_m()/world_m_to_district() pipeline, exactly as if
## the target were itself an entry read out of `river_cells`.
##
## Deliberately returns the RAW grid-adjacent position rather than looking it
## up in a `river_cells` array — a D8 downstream pointer always names a real
## adjacent grid cell by construction (that is what D8 flow direction means),
## whether or not that specific cell independently appears in whatever
## (possibly filtered) `river_cells` list the caller is iterating.
static func d8_downstream_target(row: float, col: float, downstream_raw: int) -> Variant:
if downstream_raw < 0 or downstream_raw >= RIVER_DOWNSTREAM_SENTINEL_BASE:
return null # sentinel or malformed — no real direction to decode
var delta: Vector2i = D8_DIRECTION_DELTAS[downstream_raw]
return Vector2(row + float(delta.x), col + float(delta.y))
## T-1170 Ruling 5a — pure chord-chain CONSTRUCTION (no draw calls, no water
## clip, no canvas-local conversion): given `river_cells`/`river_class`/
## `river_downstream` (the raw decoded river_network sub-dict arrays) and a
## `granularity_v2` rung tag, returns an Array of
## `{"from": Vector2, "to": Vector2, "cls": int}` dicts — one per river cell
## whose class is visible at this rung AND whose river_downstream pointer
## resolves to a real direction (not a sentinel, not out of range, not
## missing). `from`/`to` are in the SAME (row, col) heightmap-pixel domain
## `river_cells` entries themselves use — the caller converts to world
## metres/district/canvas-local and applies the water clip, exactly as if it
## had built this list inline (this function exists so that CONSTRUCTION is
## unit-testable without a live render pass — draw_line() itself requires
## one, per this cluster's own "pure function tests are the gate" draw-smoke
## caveat, so the chain-walking logic that actually decides WHICH segments
## exist must not be entangled with the draw call that paints them).
##
## Split out of AtlasWindowNatureOverlay._draw_skeleton_chords() specifically
## so a test can assert "this exact set of segments was constructed from
## this exact fixture" (including the sentinel-chain-end and malformed-input
## cases) without a SubViewport/render context — matching this file's
## existing "geometry/construction here, draw calls in the overlay node"
## split for every other piece of this cluster.
static func build_skeleton_chords(
river_cells: Array, river_class: Array, river_downstream: Array, granularity_v2: String
) -> Array:
var chords: Array = []
for idx in range(river_cells.size()):
var c: Variant = river_cells[idx]
if not (c is Array and c.size() >= 2):
continue
var cls: int = int(river_class[idx]) if idx < river_class.size() else RIVER_CLASS_FALLBACK
if not skeleton_class_visible_at_rung(cls, granularity_v2):
continue
if idx >= river_downstream.size():
continue # no downstream pointer for this cell yet — no segment
var downstream_raw: int = int(river_downstream[idx])
var row: float = float(c[0])
var col: float = float(c[1])
var target: Variant = d8_downstream_target(row, col, downstream_raw)
if target == null:
continue # sentinel (MOUTH/EDGE_DRAIN/TERMINAL) or malformed direction — chain end
chords.append({"from": Vector2(row, col), "to": target, "cls": cls})
return chords
## T-1170 Ruling 5b/3h — pure course-polyline CONSTRUCTION (no draw calls):
## given one raw `RiverCourse` dict (as decoded off the wire — `class`,
## `points` (world-metres `[x,y]` pairs), `terminus` (a bare string tag)) and
## the window's own `granularity_v2`/`held_center`/`held_n`/`cell_pixel_size`,
## returns `null` if the course should not draw at all at this rung (class
## not visible, missing/degenerate points), or
## `{"canvas_pts": PackedVector2Array, "cls": int, "terminus": String}`
## ready for the caller to draw_polyline() + terminus-marker dispatch.
##
## Class defaults to RIVER_CLASS_FALLBACK (TRUNK) when missing, the same
## graceful-decode posture as the skeleton path's river_class fallback.
## `terminus` defaults to COURSE_TERMINUS_NONE when missing — an ordinary
## interior/no-marker ending, never crashing on an old/malformed payload.
## Malformed individual points are skipped (not fatal to the whole polyline,
## matching build_skeleton_chords()'s own "skip the bad entry, keep going"
## posture) — if fewer than 2 valid points remain after skipping, returns
## `null` (nothing to draw a line between).
static func build_course_render_plan(
course: Dictionary,
granularity_v2: String,
held_center: Vector2i,
held_n: int,
cell_pixel_size: float
) -> Variant:
var cls: int = int(course.get("class", RIVER_CLASS_FALLBACK))
if not course_class_visible_at_rung(cls, granularity_v2):
return null
var points_raw: Variant = course.get("points")
if not points_raw is Array or (points_raw as Array).size() < 2:
return null
var canvas_pts: PackedVector2Array = PackedVector2Array()
for pt: Variant in points_raw:
if not (pt is Array and pt.size() >= 2):
continue # malformed point — skip it, don't fail the whole polyline
var world_m := Vector2(float(pt[0]), float(pt[1]))
canvas_pts.append(world_m_to_canvas_local(world_m, held_center, held_n, cell_pixel_size))
if canvas_pts.size() < 2:
return null # too many malformed points left too few to draw a line
var terminus: String = str(course.get("terminus", COURSE_TERMINUS_NONE))
return {"canvas_pts": canvas_pts, "cls": cls, "terminus": terminus}
# =============================================================================
# T-1156 wave 1 / T-1170: per-rung nature-overlay visibility policy READERS.
# The policy TABLES themselves live up in the top-of-file const block per
# class-definitions-order.
# =============================================================================
## Whether a river cell of `river_class` should draw on the Region+ SKELETON
## path (Ruling 5a) at `granularity_v2`. An unrecognized rung tag falls back
## to Region's (fullest) visibility set — matching this cluster's existing
## "unrecognized -> most permissive/safest already-shipped behavior" posture
## (see AtlasWindowOverlay._filter_for_granularity_v2()'s own doc for the same
## fallback shape, there choosing the safer LINEAR filter for an unknown tag).
static func skeleton_class_visible_at_rung(river_class: int, granularity_v2: String) -> bool:
var visible: Array = SKELETON_CLASS_VISIBLE_BY_RUNG.get(
granularity_v2, SKELETON_CLASS_VISIBLE_BY_RUNG["Region"]
)
return visible.has(river_class)
## Whether a river class should draw on the District/Quarter COURSE path
## (Ruling 5b) at `granularity_v2`. No Region key exists in
## COURSE_CLASS_VISIBLE_BY_RUNG (Region never draws courses) — an unrecognized
## OR Region tag both fall back to an EMPTY array (nothing visible), the
## inverse fallback posture from skeleton_class_visible_at_rung() above,
## deliberately: falling back to "show everything" for a course-path query at
## an unexpected rung would risk drawing course polylines at Region, which no
## window response ever carries (courses are windowed-only content, Ruling 1)
## — failing to EMPTY is the safe direction on this specific table.
static func course_class_visible_at_rung(river_class: int, granularity_v2: String) -> bool:
var visible: Array = COURSE_CLASS_VISIBLE_BY_RUNG.get(granularity_v2, [])
return visible.has(river_class)
static func confluences_visible_at_rung(granularity_v2: String) -> bool:
return bool(CONFLUENCES_VISIBLE_BY_RUNG.get(granularity_v2, true))
static func mouths_visible_at_rung(granularity_v2: String) -> bool:
return bool(MOUTHS_VISIBLE_BY_RUNG.get(granularity_v2, true))
static func basins_visible_at_rung(granularity_v2: String) -> bool:
return bool(BASINS_VISIBLE_BY_RUNG.get(granularity_v2, true))
static func attractors_visible_at_rung(granularity_v2: String) -> bool:
return bool(ATTRACTORS_VISIBLE_BY_RUNG.get(granularity_v2, true))
## Per-class course polyline width (screen-space px, zoom=1.0 domain — see the
## const's own doc). Falls back to the stream (thinnest) width for an
## unrecognized class id, matching RIVER_DOT_RADIUS_BY_CLASS_REGION's own
## `.get(cls, 2.2)` call-site fallback shape on the skeleton side (there the
## fallback is trunk/widest — the caller passes a literal default; here the
## table itself owns a documented fallback since this is a NAMED reader, not
## an inline `.get()`).
static func course_class_width_px(river_class: int) -> float:
return float(COURSE_CLASS_WIDTH_PX.get(river_class, COURSE_CLASS_WIDTH_PX[RIVER_CLASS_STREAM]))
## Per-class course polyline opacity multiplier on COLOR_GEN_RIVER. Same
## unrecognized-class fallback posture as course_class_width_px() above.
static func course_class_opacity(river_class: int) -> float:
return float(COURSE_CLASS_OPACITY.get(river_class, COURSE_CLASS_OPACITY[RIVER_CLASS_STREAM]))
## Coordinator live-eyeball finding (2026-07-23): Araminta's ruling specifies
## nature-overlay marker sizes as SCREEN-SPACE px, constant regardless of
## zoom — but every draw call in this cluster (river dots, mouth rings, basin
## line widths, T-1170 course polylines/chords) executes inside `_canvas`, a
## Node2D whose `.scale` IS `_view_zoom` (AtlasWindowViewer._apply_transform()).
## A raw radius/width constant handed to draw_circle()/draw_arc()/
## draw_polyline() therefore gets multiplied by `_view_zoom` at render time —
## invisible at the Region orbital tile mosaic's fit zoom (~0.0063 for Lendel:
## a 2.2px trunk-river dot rasterizes at ~0.014 screen px, sub-pixel), even
## though the SAME drawing code produces a correctly-sized (visible) mouth
## ring at District's much larger fit zoom (~3.75, live capture confirmed
## this). The fix: every marker's draw-time radius/width must be pre-divided
## by `view_zoom` so the canvas transform's multiply cancels back out to the
## ruling's literal screen-space value. `view_zoom` is clamped to a small
## positive floor (MIN_ZOOM's own order of magnitude) to avoid a
## divide-by-zero/near-zero blowup on a degenerate zero-zoom caller — this
## floor is far below any legal `_view_zoom` (AtlasWindowViewer.MIN_ZOOM =
## 0.0005), so it is inert for every real caller and only guards a malformed
## test input.
static func zoom_compensated_size(screen_space_size: float, view_zoom: float) -> float:
return screen_space_size / maxf(view_zoom, 0.0001)
## T-1170 live-round finding (2026-07-23, coordinator/Araminta's pixel-scan
## of the course captures — D-district-courses.png/Q-quarter-courses.png
## showed a UNIFORM 1px hairline for the entire course, no width/opacity
## variation at all): zoom_compensated_size() is correct arithmetic (verified:
## 2.2 / 3.75 = 0.5867, and 0.5867 * 3.75 round-trips to 2.2 exactly — the
## compensation MATH has never been the bug), but it has NO FLOOR against
## Godot's own STROKE-WIDTH rasterizer minimum — confirmed empirically via a
## live A/B bracket (temporary instrumentation, since reverted): draw_line()/
## draw_polyline() called with a width in [0.6, 1.0) canvas-local units
## renders as a flat 1px hairline REGARDLESS of the input value, identically
## on both APIs (ruling out a draw_polyline()-specific quirk) — Godot's line
## rasterizer treats any width below ~1.0 the same as its historical
## width=-1.0 "hairline" sentinel, rather than continuing to shrink the
## antialiased stroke sub-pixel the way draw_circle()'s radius parameter
## does (mouth rings at the SAME District/Quarter zoom levels render
## correctly-sized — confirmed, radii have no equivalent floor).
##
## District/Quarter fit zooms (3.75/7.5+, and the player can zoom further
## within a rung) divide COURSE_CLASS_WIDTH_PX's 0.9-2.2px table values down
## to 0.12-0.59 canvas-local units — BELOW the 1.0 floor — so every course
## class collapses to the identical hairline the moment view_zoom exceeds
## roughly `screen_space_size` itself. This is the STROKE-WIDTH-SPECIFIC
## sibling of zoom_compensated_size() (which remains correct and unchanged
## for radii/point sizes, its own existing floor is a divide-by-zero guard
## only, not a rasterizer-minimum guard) — a SEPARATE function because the
## two draw families have genuinely different Godot-side minimums, not a
## single shared bug.
##
## The fix clamps the OUTPUT to a 1.0 canvas-local-unit floor — the closest
## representable value to "as thin as Godot's rasterizer can actually draw a
## non-hairline stroke" — rather than letting the divide produce a
## sub-floor value that Godot silently reinterprets as hairline anyway. This
## is an honest floor, not a workaround: below it, EVERY value (0.373, 0.6,
## 0.9999...) already rendered identically as hairline before this fix, so
## clamping to exactly 1.0 changes nothing about what could already be drawn
## at that zoom — it only stops different classes/rungs from silently
## collapsing to the SAME wrong result and starts drawing the class/opacity
## variation the ruling specifies. At extreme zoom-in (small view_zoom
## relative to the literal px value) the floor never engages — the same
## divide-then-scale math takes over exactly as design intends, matching
## zoom_compensated_size()'s own behavior at Region's tiny fit zoom.
static func zoom_compensated_stroke_width(screen_space_size: float, view_zoom: float) -> float:
return maxf(zoom_compensated_size(screen_space_size, view_zoom), 1.0)
## T-1170 live round (2026-07-23, coordinator's mouth-ring finding): the
## RADIUS-SMALLER-THAN-STROKE regime — a THIRD sibling to
## zoom_compensated_size()/zoom_compensated_stroke_width(), needed
## specifically for draw_arc() RING markers (mouth rings — the only
## draw_arc() caller in this file whose radius and stroke width are BOTH
## small, zoom-compensated values that can cross each other).
##
## Root-cause evidence (live A/B bracket, temporary instrumentation, since
## reverted — real running client via SR_LIVE=1, real x11/opengl3 driver):
## the ORIGINAL "zero ring pixels" report turned out to be a SEPARATE,
## already-correct-code issue — the terminus point legitimately sits near
## the requesting window's own edge, and the fit-and-center COVER strategy
## crops that edge off the visible viewport (screen_p verified computed as
## (1755, -191) against a 1920x1080 frame — above the top edge, not a
## drawing bug). Panning the view to re-center the SAME point (verified via
## AtlasWindowViewer.set_view()) proves the ring genuinely draws — but as a
## SOLID BLOB, not a hollow ring, at the production radius/stroke pair
## (radius=0.667, stroke=1.0 canvas-local units, Quarter fit zoom 7.5):
## draw_arc()'s stroke, centered ON the radius circle, extends inward past
## the circle's own center once stroke exceeds ~1x the radius, filling the
## hole. Bracket results (stroke fixed at 1.0 canvas-local, radius varied):
## radius=0.51 (~stroke/2) -> still a solid blob; radius=1.0 (=stroke) ->
## solid blob (the production case); radius=1.5 (1.5x stroke) -> hollow ring
## recovers; radius=2.0 (2x stroke) -> hollow ring, cleaner. The blob
## persists past the naive geometric threshold (radius > stroke/2, where an
## infinitely-thin/perfectly-antialiased ring would already have a hole)
## because draw_arc()'s low tessellation (18 points, this file's own call)
## plus antialiasing blur eat into the theoretical hole at these tiny
## absolute magnitudes — an empirical floor, not a derived one, chosen with
## margin over the observed 1.0x-blob/1.5x-hollow transition rather than
## shaving the boundary exactly.
##
## The fix: floor the RADIUS at `stroke * RING_RADIUS_STROKE_MULTIPLIER`
## (2.0, the top-of-file const — verified clean in the bracket above)
## whenever the naive zoom-compensated radius would fall below it — the
## same "floor the OUTPUT, never let a sub-threshold value reach Godot's
## renderer" pattern zoom_compensated_stroke_width() already established,
## applied to the paired radius/stroke relationship a lone-value floor
## can't express (unlike the stroke-width floor, this one's threshold is
## RELATIVE to another draw-time value, not an absolute constant). At every
## zoom where the naive radius already clears the floor on its own
## (Region's dot radii, or any District/Quarter case wide enough), this is
## an exact no-op — identical to calling zoom_compensated_size() directly.
static func zoom_compensated_ring_radius(
screen_space_radius: float, stroke_width_canvas_local: float, view_zoom: float
) -> float:
var naive_radius: float = zoom_compensated_size(screen_space_radius, view_zoom)
return maxf(naive_radius, stroke_width_canvas_local * RING_RADIUS_STROKE_MULTIPLIER)
@@ -1,750 +0,0 @@
extends Node2D
## Draws the whole-body Layer-1 nature overlays (rivers/basins/attractors,
## T-1156 wave 1) on the AtlasWindowViewer zoom ladder. Child of
## AtlasWindowViewer._canvas, ABOVE the terrain composite (AtlasWindowOverlay)
## and below UI chrome — same parent, same pan/zoom transform, drawn after so
## river dots/basin fills sit on top of the terrain colorizer.
##
## T-1170 (Ruling 5a): the Region+ river dot-scatter upgraded to CONNECTED
## STRAIGHT CHORDS via river_downstream (_draw_skeleton_chords()) — per
## Ruling 3b this chord chain IS the rung-truncated course at Region
## truncation, not an approximation of it. District/Quarter no longer draw
## the (now-retired) dot-scatter at all; they draw windowed course polylines
## instead (Ruling 5b, _draw_courses()).
##
## T-1170 Ruling 5b (B3): course polylines ride `DistrictWindowLayer.courses`
## — a SEPARATE data source from `_layer1` above (courses arrive on the
## WINDOWED response, `viewer.get_district_window()`, not the whole-body
## Layer-1 response this node requests via request_layer1()). _draw() is
## therefore two INDEPENDENT gates, not one: the skeleton/basin/attractor
## path gates on `_layer1 != null` (unchanged); the course path gates on
## `viewer.get_district_window()` being a Dictionary with a `courses` key,
## entirely independent of whether Layer-1 has arrived yet — a player who
## descends straight to District without the whole-body fetch completing
## still sees courses the moment the window arrives. NO T-1172 water clip on
## this path (Ruling 3g) — courses carry real rung-consistent termini
## server-side (the whole POINT of windowing course invention, Ruling 1d).
##
## This is a PORT, not a reactivation, of the retired planetary-screen draw
## code (atlas_marker_overlay.gd:523-572, _draw_gen_rivers/_draw_gen_basins/
## _draw_gen_attractors) — atlas_marker_overlay.gd stays retired/unreachable.
## The drawing IDEAS survive (polygon basins, glyph-free double-ring mouths,
## draw order basins-under-rivers-under-attractors; the dot-scatter idea
## itself is superseded at Region by T-1170's chord chain, see above); the
## COORDINATE MAPPING does not — the retired code projected onto a resident
## displayed heightmap TEXTURE (_gen_pos(), texture-fraction space) that this
## ladder screen has no equivalent of. Positions here go all the way through
## world metres -> district -> canvas-local
## (AtlasWindowGeometryNature.layer1_pixel_to_canvas_local()), the same frame every
## other drawn feature on this screen already shares, wrap-resolved exactly
## like the tile mosaic resolves terrain tiles.
##
## Data source: the WHOLE-BODY Layer-1 response (`layer1` key on the shared
## AtlasLayerResponse envelope, SimBridge.atlas_layers_received) — a SEPARATE
## fetch from the windowed DistrictWindowLayer composite AtlasWindowOverlay
## draws (both responses ride the SAME signal, discriminated by which
## envelope key is populated — SimBridge/atlas_map_protocol.gd's decode
## always includes both `layer1` and `district_window` keys, only one
## non-null per response, per that decoder's own doc). This node connects to
## SimBridge.atlas_layers_received DIRECTLY (mirroring
## atlas_window_tile_set.gd's own "one shared inbound signal, N independent
## consumers filtering by their own criteria" shape) rather than being routed
## through AtlasWindowViewer's own _on_atlas_layers_received() — the viewer
## stays at the gdlint max-file-lines cap with this node needing zero new
## lines in that function. Requested once per body entry via request_layer1()
## (call sites: the viewer's _enter_at_rung() and _enter_tile_mode() — every
## fresh descent funnels through one of those two; enter() itself is a thin
## wrapper over _enter_at_rung and has no call of its own), which owns
## clearing stale data on a body change itself
## (see that function's own doc, no separate reset() call needed) — cached
## thereafter, rivers are static per body, no re-request on pan/zoom/rung
## crossing.
##
## No `class_name` on purpose, matching every other viewer-owned helper in
## this cluster (atlas_overlay_bar.gd/atlas_window_request.gd/
## atlas_window_tile_set.gd, review #8 precedent): the owner
## (AtlasWindowViewer) passes itself to `_init()`.
##
## Redraw wiring: no _process() poll — AtlasWindowViewer/AtlasWindowOverlay
## already call `_overlay_node.queue_redraw()` on every pan/zoom/rung-
## crossing/window-arrival event; adding this node as a SIBLING of
## AtlasWindowOverlay under `_canvas` means the viewer's existing redraw call
## sites need exactly one more line each (`_nature_overlay.queue_redraw()`
## alongside the existing `_overlay_node.queue_redraw()`) — trivial wiring on
## the viewer, no new redraw PATH. This node's OWN _on_atlas_layers_received()
## also queue_redraw()s directly (the direct-signal-connection path bypasses
## the viewer's own arrival redraw calls, so it must trigger its own). Once
## layer1 arrives for a body it never goes stale until the next
## enter()/enter_orbital(), so unlike the tile mosaic's pending-tile poll,
## this node never needs a _process() self-heal.
const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
# T-1170: the nature-overlay pixel-mapping + per-rung visibility policy split
# out of atlas_window_geometry.gd — see that file's own doc.
const AtlasWindowGeometryNature := preload("res://ui/implant/apps/atlas/atlas_window_geometry_nature.gd")
const AtlasDescendGeometryRef := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd")
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
# T-1172: two-waterline clip — see that file's own header doc.
const AtlasWindowWaterClip := preload("res://ui/implant/apps/atlas/atlas_window_water_clip.gd")
## Reused verbatim from the retired atlas_marker_overlay.gd (Araminta's
## ruling: "reuse the retired palette exactly") — same values, same source of
## truth, just no longer read from the retired file.
const COLOR_GEN_RIVER: Color = Color(0.353, 0.647, 0.776, 1.0)
const COLOR_GEN_MOUTH: Color = Color(0.353, 0.647, 0.776, 1.0)
const COLOR_GEN_BASIN_FILL: Color = Color(0.20, 0.35, 0.55, 0.06)
const COLOR_GEN_BASIN_LINE: Color = Color(0.45, 0.65, 0.85, 0.45)
var viewer = null # AtlasWindowViewer (untyped to avoid cyclic ref)
## Whole-body Layer1Output dict (river_network/drainage_basins/attractors/
## grid_w/grid_h), or null before the first response for the current body.
## Set by _on_atlas_layers_received() (this node's own direct signal
## connection); request_layer1()/reset() manage the request lifecycle.
var _layer1: Variant = null
var _requested_body_id: String = ""
func _init(viewer_ref = null) -> void:
viewer = viewer_ref
func _ready() -> void:
SimBridge.atlas_layers_received.connect(_on_atlas_layers_received)
func _exit_tree() -> void:
if SimBridge.atlas_layers_received.is_connected(_on_atlas_layers_received):
SimBridge.atlas_layers_received.disconnect(_on_atlas_layers_received)
## Adopt a Layer-1 response — ignores non-Layer1 responses (the windowed
## DistrictWindowLayer envelope leaves `layer1` null, per
## atlas_response_from_raw()'s own doc) and responses for a body this node
## didn't ask for (the player navigated away while the request was in
## flight, or this node never issued a request at all — an empty
## `_requested_body_id` must never match an empty response `body_id`,
## matching request_layer1()'s own "empty body_id is never a valid request"
## guard), matching AtlasGenerationProxy.on_response()'s own staleness guard.
func _on_atlas_layers_received(response: Dictionary) -> void:
if _requested_body_id.is_empty():
return
var layer1: Variant = response.get("layer1")
if layer1 == null:
return
if str(response.get("body_id", "")) != _requested_body_id:
return
_layer1 = layer1
queue_redraw()
## Request a body's Layer-1 data — the ONE entry point every fresh-descent
## viewer path (enter()/_enter_at_rung()/_enter_tile_mode()) calls, so it owns
## its own reset-on-body-change instead of requiring callers to remember to
## call reset() first (a single call site per entry path stays a one-line
## addition to atlas_window_viewer.gd, keeping that file at its gdlint cap).
## Idempotent per body — a second call for the SAME body_id (e.g. a
## re-entrant enter_orbital()/rung-crossing re-descent on the body already
## showing) is a no-op, keeping the held data drawn rather than flashing it
## away and re-fetching. A DIFFERENT body_id clears the stale data FIRST (so
## the previous body's rivers never draw over the new body's terrain during
## the gap) then re-requests. No-op on an empty body_id (matches
## AtlasGenerationProxy.request()'s own guard).
func request_layer1(body_id: String) -> void:
if body_id.is_empty():
return
if body_id == _requested_body_id and _layer1 != null:
return
if body_id != _requested_body_id:
_layer1 = null
_requested_body_id = body_id
SimBridge.request_atlas_layers(body_id)
func get_layer1() -> Variant:
return _layer1
## T-1170 (B3): TWO INDEPENDENT draw gates, not one — see the class doc's own
## "two independent gates" paragraph. The skeleton/basin/attractor path
## (Layer-1, whole-body) is unchanged from wave 1; the course path (windowed,
## Ruling 5b) is a SEPARATE early-return chain reaching _draw_courses(),
## checked regardless of whether `_layer1` has arrived — a player descending
## straight into District/Quarter must see courses without waiting on the
## whole-body Layer-1 fetch this node happens to also own.
func _draw() -> void:
if viewer == null:
return
_draw_skeleton_path()
_draw_course_path()
## The pre-T-1170 draw gate, unchanged in shape: whole-body Layer-1
## (rivers/basins/attractors), gated on `_layer1` having arrived.
func _draw_skeleton_path() -> void:
if _layer1 == null:
return
var rn: Variant = _layer1.get("river_network")
if not rn is Dictionary:
return
var grid_w: float = float(_layer1.get("grid_w", 0))
var grid_h: float = float(_layer1.get("grid_h", 0))
if grid_w <= 0.0 or grid_h <= 0.0:
return
var granularity_v2: String = viewer.get_held_granularity_v2()
var radius_km: float = viewer.get_body_radius_km()
var held_center: Vector2i = viewer.get_held_center()
var held_n: int = viewer.get_held_n()
var cell_px: float = viewer.get_cell_pixel_size()
var cols: int = _cols_for_wrap(radius_km)
var ctx := {
"grid_w": grid_w,
"grid_h": grid_h,
"radius_km": radius_km,
"held_center": held_center,
"held_n": held_n,
"cell_px": cell_px,
"cols": cols,
"granularity_v2": granularity_v2,
# Coordinator live-eyeball finding (2026-07-23): every marker size
# below is drawn as a SCREEN-SPACE constant (Araminta's ruling), but
# draw calls execute inside _canvas, whose .scale IS view_zoom — a
# raw constant gets multiplied by that transform at render time,
# invisible at the Region orbital tile mosaic's tiny fit zoom
# (~0.006). zs() below pre-divides by view_zoom so the transform's
# multiply cancels back to the literal screen-space value. See
# AtlasWindowGeometryNature.zoom_compensated_size()'s own doc.
"view_zoom": viewer.get_view_zoom(),
}
if AtlasWindowGeometryNature.basins_visible_at_rung(granularity_v2) and viewer.is_overlay_visible(
"gen_basins"
):
_draw_basins(ctx)
if viewer.is_overlay_visible("gen_rivers"):
_draw_rivers(rn, ctx)
if AtlasWindowGeometryNature.attractors_visible_at_rung(granularity_v2) and viewer.is_overlay_visible(
"gen_attractors"
):
_draw_attractors(ctx)
## T-1170 Ruling 5b (B3): the District/Quarter windowed COURSE path — an
## entirely separate data source (`viewer.get_district_window()`) and draw
## gate from _draw_skeleton_path() above. Gated on `gen_rivers` (the SAME
## overlay-bar toggle the skeleton path uses — one player-facing "rivers"
## toggle covers both presentation surfaces, matching Araminta's ruling that
## the two paths are one continuous feature from the player's perspective,
## not two separate layers to independently show/hide).
##
## Region NEVER reaches this function's draw calls (course_class_visible_at_
## rung() has no Region key, always false there — Region draws the skeleton
## chord chain, never windowed course content, per Ruling 1). Tile mode
## (the Region orbital mosaic) also never carries `district_window` data at
## all (get_district_window() is single-window-mode-only, per that
## accessor's own doc — courses simply never reach this path in tile mode by
## construction, no separate is_tile_mode() guard needed here).
func _draw_course_path() -> void:
if not viewer.is_overlay_visible("gen_rivers"):
return
var window: Variant = viewer.get_district_window()
if not window is Dictionary:
return
var w: Dictionary = window
var courses: Variant = w.get("courses")
if not courses is Array:
return # missing `courses` field (old/pre-A2 payload) — draw nothing, see class doc
var granularity_v2: String = str(w.get("granularity_v2", "District"))
var ctx := {
"held_center": viewer.get_held_center(),
"held_n": viewer.get_held_n(),
"cell_px": viewer.get_cell_pixel_size(),
"granularity_v2": granularity_v2,
"view_zoom": viewer.get_view_zoom(),
}
for course: Variant in courses:
if not course is Dictionary:
continue
_draw_one_course(course, ctx)
## T-1170 Ruling 5b: one RiverCourse's polyline draw — class-filtered per
## COURSE_CLASS_VISIBLE_BY_RUNG, width/opacity from the COURSE_CLASS_WIDTH_PX/
## COURSE_CLASS_OPACITY companion tables (zoom-compensated via _zs(), the SAME
## discipline every other marker in this cluster follows — PR #195's
## stroke-width miss is the standing regression class this exists to
## prevent). Points arrive as `Vec<(i32,i32)>` WORLD METRES (Ruling 3h, NOT
## heightmap pixels — see world_m_to_canvas_local()'s own doc for why this is
## one conversion step shorter than the skeleton path). NO T-1172 water
## clip on this path (Ruling 3g) — courses carry real rung-consistent
## termini server-side; that is the entire point of windowing course
## invention (Ruling 1d).
##
## Terminus handling (Ruling 3h's CourseTerminus vocabulary):
## - Mouth: double-ring at the LAST point (the real coast anchor — mouths
## return as real geometry here, per Ruling 3g/3e).
## - EdgeDrain: no ring (Ruling 3f — pole-edge drains are grid artifacts,
## not river-meets-sea events; same disposition as the skeleton path's
## EDGE_DRAIN sentinel).
## - ContinuesBeyondWindow: draw to the last point, no marker (the course
## keeps going outside this window's crop — nothing to mark AT this
## window's edge, the polyline simply stops because the data stops).
## - None: an ordinary interior terminus (a headwater/confluence anchor
## inside this window) — no marker, same as ContinuesBeyondWindow's "just
## stop drawing" treatment; the two differ in MEANING (why the points ran
## out) but not in PRESENTATION (neither gets a ring).
## The actual gating/construction (class visibility, point decode, terminus
## lookup) is delegated to AtlasWindowGeometryNature.build_course_render_plan()
## — a pure function with no draw calls, unit-tested directly in
## test_atlas_window_geometry_nature.gd, the SAME split B2's
## build_skeleton_chords() already established. This function's own job is
## just the draw calls the plan feeds.
func _draw_one_course(course: Dictionary, ctx: Dictionary) -> void:
var plan: Variant = AtlasWindowGeometryNature.build_course_render_plan(
course, ctx["granularity_v2"], ctx["held_center"], ctx["held_n"], ctx["cell_px"]
)
if plan == null:
return
var canvas_pts: PackedVector2Array = plan["canvas_pts"]
var cls: int = plan["cls"]
var terminus: String = plan["terminus"]
var width: float = _zs_stroke(AtlasWindowGeometryNature.course_class_width_px(cls), ctx)
var opacity: float = AtlasWindowGeometryNature.course_class_opacity(cls)
var color := Color(COLOR_GEN_RIVER.r, COLOR_GEN_RIVER.g, COLOR_GEN_RIVER.b, COLOR_GEN_RIVER.a * opacity)
draw_polyline(canvas_pts, color, width)
if terminus == AtlasWindowGeometryNature.COURSE_TERMINUS_MOUTH:
_draw_mouth(canvas_pts[canvas_pts.size() - 1], ctx)
# EdgeDrain/ContinuesBeyondWindow/None: no marker — draw to the last
# point and stop, per the doc above.
## Circumference in districts, for nearest_wrap_image()'s wrap resolution —
## mirrors AtlasWindowOverlay._draw_tile_mosaic()'s own `cols` computation
## exactly (same source, same reason: only meaningful in tile/orbital mode on
## a real body; a no-radius body has no periodicity, `cols=0` is
## nearest_wrap_image()'s own documented no-op passthrough).
func _cols_for_wrap(radius_km: float) -> int:
if radius_km <= 0.0:
return 0
return int(AtlasDescendGeometryRef.district_extent(radius_km).get("cols", 0))
## Zoom-compensated screen-space size — thin per-ctx wrapper over
## AtlasWindowGeometryNature.zoom_compensated_size() (see that function's own
## doc for the "why divide" rationale). Every draw_circle()/draw_arc() RADIUS
## in this file routes through this so Araminta's "constant on-screen size"
## ruling holds at every rung/zoom. NOT for stroke widths — see _zs_stroke()
## below, added T-1170 live round (2026-07-23) after the course-polyline
## hairline finding: draw_line()/draw_polyline() STROKE WIDTH arguments have
## a Godot-side rasterizer floor radii don't share (confirmed empirically —
## zoom_compensated_stroke_width()'s own doc has the full A/B evidence).
func _zs(screen_space_size: float, ctx: Dictionary) -> float:
return AtlasWindowGeometryNature.zoom_compensated_size(screen_space_size, ctx["view_zoom"])
## T-1170 live round (2026-07-23): the STROKE-WIDTH-specific sibling of
## _zs() — every draw_line()/draw_polyline()/draw_arc() STROKE WIDTH
## argument (never a radius/point-size) in this file routes through this
## instead of _zs(), so the width never crosses Godot's ~1.0-canvas-local-
## unit line-rasterizer floor and silently collapses to an
## indistinguishable hairline. See
## AtlasWindowGeometryNature.zoom_compensated_stroke_width()'s own doc for
## the full live-repro evidence (the course-path pixel scan that found this:
## a uniform 1px hairline with zero class/width variation in both District
## and Quarter captures).
func _zs_stroke(screen_space_size: float, ctx: Dictionary) -> float:
return AtlasWindowGeometryNature.zoom_compensated_stroke_width(screen_space_size, ctx["view_zoom"])
## T-1170 live round (2026-07-23, coordinator's mouth-ring blob finding): the
## RING-RADIUS-specific sibling of _zs()/_zs_stroke() — every draw_arc() ring
## marker whose radius and stroke are BOTH small, zoom-compensated values
## (currently: the two _draw_mouth() rings) routes its RADIUS through this
## instead of plain _zs(), so the radius never falls at-or-below its own
## paired stroke width and degenerates from a hollow ring into a solid blob.
## See AtlasWindowGeometryNature.zoom_compensated_ring_radius()'s own doc for
## the full A/B bracket evidence (radius=1x stroke -> blob, 1.5x -> hollow,
## floor set at 2x with margin). `stroke_canvas_local` is the ALREADY
## zoom-compensated stroke value (this function's own caller passes
## _zs_stroke()'s result, not a raw screen-space width) — the floor compares
## against the SAME canvas-local units the naive radius divide produces.
func _zs_ring_radius(screen_space_radius: float, stroke_canvas_local: float, ctx: Dictionary) -> float:
return AtlasWindowGeometryNature.zoom_compensated_ring_radius(
screen_space_radius, stroke_canvas_local, ctx["view_zoom"]
)
## Pixel (row, col) -> fractional district position, wrap-resolved against
## the currently-HELD view's own center (ctx["held_center"]) — the
## representative wrap-image _pos()'s canvas conversion needs. T-1172: this
## is also the value fed to the water-clip lookup (_is_drawn_water()) — NOT
## a separately-computed position — so the clip test and the actual drawn
## position can never disagree about which longitude wrap-image is meant.
## Tile-mode's own per-tile wrap re-resolution (AtlasWindowWaterClip.
## resolve_morphology_zone()) re-derives whichever wrap-image a SPECIFIC
## tile needs internally; feeding it this held-center-wrapped value is a
## safe, consistent starting representative either way (longitude is
## periodic — any wrap-image of the same district resolves to the same
## real-world position).
func _district(row: float, col: float, ctx: Dictionary) -> Vector2:
var world_m: Vector2 = AtlasWindowGeometryNature.layer1_pixel_to_world_m(
row, col, ctx["grid_w"], ctx["grid_h"], ctx["radius_km"]
)
var district: Vector2 = AtlasWindowGeometryNature.world_m_to_district(world_m)
var cols: int = ctx["cols"]
if cols > 0:
var held_center: Vector2i = ctx["held_center"]
var wrapped_col: float = float(
AtlasWindowGeometry.nearest_wrap_image(roundi(district.x), held_center.x, cols)
)
# Preserve the SUB-district fractional offset nearest_wrap_image()'s
# integer rounding would otherwise discard — river dots are not
# district-lattice-snapped (see world_m_to_district()'s own doc).
district.x = wrapped_col + (district.x - roundi(district.x))
return district
## Pixel (row, col) -> canvas-local, wrap-resolved to whichever longitude
## image is nearest the currently-held view — the SAME two-step
## (map-then-nearest-wrap) the tile mosaic draw path uses, just for a single
## point instead of a tile's four corners. Thin wrapper over _district() +
## AtlasWindowGeometry.district_to_canvas_local() (T-1172 split: callers that
## also need the water-clip test call _district() directly instead, so the
## SAME resolved district feeds both the draw position and the clip check).
func _pos(row: float, col: float, ctx: Dictionary) -> Vector2:
return AtlasWindowGeometry.district_to_canvas_local(
_district(row, col, ctx), ctx["held_center"], ctx["held_n"], ctx["cell_px"]
)
## T-1172: whether the composite cell covering `district` is drawn as water
## (OpenOcean/Lake) — FAILS OPEN (returns false, "not water", i.e. draw the
## dot) when no arrived composite data covers the position, per Tyre's rule
## 5 ("the clip is a presentation refinement, never a data gate"). Resolves
## through AtlasWindowWaterClip.resolve_morphology_zone(), which handles
## BOTH the single-window rung path and Region tile mode internally — this
## function never branches on viewer.is_tile_mode() itself, matching that
## function's own "single dispatch point" doc. `ctx["held_center"].x` is
## threaded through (live round 2, coordinator's trace) — tile-mode
## resolution must wrap each tile's CENTER toward held_center EXACTLY like
## AtlasWindowOverlay._draw_tile_mosaic()'s own `draw_col` computation, or
## the clip silently tests the wrong wrap-image of a seam tile (see
## resolve_morphology_zone()'s own doc for the live repro).
func _is_drawn_water(district: Vector2, ctx: Dictionary) -> bool:
var is_tile_mode: bool = viewer.is_tile_mode()
var single_window: Variant = null if is_tile_mode else viewer.get_district_window()
var tiles: Array = []
if is_tile_mode:
var tile_set = viewer.get_tile_set()
if tile_set != null:
tiles = tile_set.get_tiles()
var held_center: Vector2i = ctx["held_center"]
var zone: int = AtlasWindowWaterClip.resolve_morphology_zone(
district, is_tile_mode, single_window, tiles, ctx["cols"], held_center.x
)
if zone == AtlasWindowWaterClip.MORPHOLOGY_ZONE_NO_DATA:
return false
return AtlasOverlayColors.is_morphology_water(zone)
## T-1172 clip — RETAINED for this Region-skeleton path only (Ruling 3g: the
## clip retires for the District/Quarter COURSE-drawing rungs — see
## _draw_courses() below — because courses carry real rung-consistent
## termini and the clip's job is done there; the Region skeleton path keeps
## drawing against a rung-dependent drawn coast and needs the presentation-
## frame reconciliation until Region itself goes windowed, T-1143 ruling 2).
## River cells, confluences, and mouths are each dropped (strict, no snap)
## when their resolved composite cell reads as drawn water — see
## AtlasWindowWaterClip's own header doc for the two-waterline rationale.
## Basins are explicitly OUT OF SCOPE (Tyre's rule 4) — untouched.
##
## T-1170 Ruling 5a: at Region+, river cells draw as CONNECTED STRAIGHT
## CHORDS (each river cell to its river_downstream neighbor) instead of a
## dot-scatter — see _draw_skeleton_chords() below, called from here.
## District/Quarter no longer reach this function's river-cell/confluence
## loop at all (SKELETON_CLASS_VISIBLE_BY_RUNG has empty District/Quarter
## entries) — they draw via _draw_courses() instead (Ruling 5b), wired from
## _draw().
func _draw_rivers(rn: Dictionary, ctx: Dictionary) -> void:
var granularity_v2: String = ctx["granularity_v2"]
_draw_skeleton_chords(rn, ctx)
if AtlasWindowGeometryNature.confluences_visible_at_rung(granularity_v2):
for cf: Variant in rn.get("confluences", []):
if cf is Array and cf.size() >= 2:
var district: Vector2 = _district(float(cf[0]), float(cf[1]), ctx)
if _is_drawn_water(district, ctx):
continue
var p: Vector2 = AtlasWindowGeometry.district_to_canvas_local(
district, ctx["held_center"], ctx["held_n"], ctx["cell_px"]
)
var radius: float = _zs(AtlasWindowGeometryNature.RIVER_CONFLUENCE_RADIUS_REGION, ctx)
draw_circle(p, radius, COLOR_GEN_RIVER)
if AtlasWindowGeometryNature.mouths_visible_at_rung(granularity_v2):
for m: Variant in rn.get("mouths", []):
if m is Array and m.size() >= 2:
var district: Vector2 = _district(float(m[0]), float(m[1]), ctx)
# T-1172 rule 3: mouths are SUPPRESSED (not snapped, not
# dimmed) when their cell reads as drawn water — a mouth is
# the worst-case disagreement by construction (the last LAND
# cell on the RAW coast; wherever the drawn coast is
# displaced inland, the mouth renders offshore).
if _is_drawn_water(district, ctx):
continue
var p: Vector2 = AtlasWindowGeometry.district_to_canvas_local(
district, ctx["held_center"], ctx["held_n"], ctx["cell_px"]
)
_draw_mouth(p, ctx)
## T-1170 Ruling 5a — the Region+ skeleton-chord draw: each river cell whose
## class is visible at this rung draws a STRAIGHT LINE SEGMENT to its
## `river_downstream` neighbor. `river_network.river_downstream` is a u8 PER
## RIVER CELL (index-aligned with river_cells, the SAME alignment convention
## river_class already uses) encoding a **D8 DIRECTION** (0-7, see
## AtlasWindowGeometryNature.D8_DIRECTION_DELTAS — NOT a river_cells index;
## the target cell's grid position is `c + delta`, decoded via
## AtlasWindowGeometryNature.d8_downstream_target()), with SENTINEL values
## `>= RIVER_DOWNSTREAM_SENTINEL_BASE` for MOUTH/EDGE_DRAIN/reserved-TERMINAL
## (Ruling 2c). Direction-index convention and sentinel values CONFIRMED
## against Dudley's A1 (server/src/atlas/drainage.rs:35-44, landed
## 0fea69feb; relayed by the coordinator, not guessed) — MOUTH=8,
## EDGE_DRAIN=9, TERMINAL=10 (reserved/unused), directions 0-7 = N/S/E/W/NE/
## NW/SE/SW. Every place this convention is encoded is a SINGLE named
## constant group on AtlasWindowGeometryNature (D8_DIRECTION_DELTAS /
## RIVER_DOWNSTREAM_SENTINEL_BASE / RIVER_DOWNSTREAM_MOUTH /
## RIVER_DOWNSTREAM_EDGE_DRAIN / RIVER_DOWNSTREAM_TERMINAL) — see that file's
## own doc.
##
## Per Ruling 3b, these chords ARE the rung-truncated course at Region (no
## octave warp survives at Region spacing — the invented course degenerates
## exactly to this chord), NOT an approximation of it — one function (the
## server's course inventor, eventually), every rung, this is simply what it
## looks like with zero surviving octaves.
##
## Sentinel dispositions: MOUTH and EDGE_DRAIN both END the chain — no
## downstream segment is drawn for a sentinel-terminated cell (there is no
## real neighbor cell to connect to). EDGE_DRAIN gets NO mouth ring (Ruling
## 3f — pole-edge drains are grid artifacts, not river-meets-sea events; the
## existing mouths array/_draw_mouth() call in _draw_rivers() is already
## scoped to real MOUTH sentinels via rn["mouths"], server-side, per Ruling
## 3f's "extract_river_network stops classifying grid-edge exits into
## mouths" — this function draws NO ring at all, sentinel or otherwise, that
## is _draw_rivers()'s mouths-array job).
##
## `river_downstream` missing or shorter than `river_cells` (pre-T-1170
## payload — Dudley's `#[serde(default)]` empty-Vec contract, the exact same
## graceful-decode shape river_class already established) means NO chord
## segment can be drawn for that index at all (there is no real downstream
## direction to connect to, unlike the class-fallback case where TRUNK is a
## safe visual default) — those cells draw NOTHING at Region until the field
## arrives, a graceful (not crashing) degradation, documented here rather
## than silently falling back to the old dot-scatter (which would require
## carrying that whole second code path forward past this ticket). The
## decoded target cell is ALSO not required to appear in `river_cells` itself
## (the chord draws to the raw grid position `c + delta`, not to a looked-up
## river-cell entry) — a downstream D8 pointer always names a real adjacent
## grid cell by construction, whether or not that cell independently made it
## into the (possibly rung/threshold-filtered) `river_cells` list.
##
## The actual chain-CONSTRUCTION (which segments exist at all, given the
## fixture and rung) is delegated to
## AtlasWindowGeometryNature.build_skeleton_chords() — a pure function with
## no draw calls, unit-tested directly in
## test_atlas_window_geometry_nature.gd (the sentinel/malformed/visibility
## cases). This function's own job is the remaining per-segment work that DOES
## need the overlay's own state: the water clip (_segment_touches_drawn_water(),
## needs the composite/tile data only the overlay holds) and the actual
## draw_line() call (needs a live render pass).
func _draw_skeleton_chords(rn: Dictionary, ctx: Dictionary) -> void:
var granularity_v2: String = ctx["granularity_v2"]
var river_cells: Array = rn.get("river_cells", [])
var river_class: Array = rn.get("river_class", [])
var river_downstream: Array = rn.get("river_downstream", [])
var chords: Array = AtlasWindowGeometryNature.build_skeleton_chords(
river_cells, river_class, river_downstream, granularity_v2
)
for chord: Dictionary in chords:
var from_rc: Vector2 = chord["from"]
var to_rc: Vector2 = chord["to"]
var cls: int = chord["cls"]
var from_district: Vector2 = _district(from_rc.x, from_rc.y, ctx)
var to_district: Vector2 = _district(to_rc.x, to_rc.y, ctx)
# T-1172 clip (Region-only, retained per Ruling 3g): a segment is
# clipped when EITHER endpoint OR its midpoint resolves to drawn
# water — see _segment_touches_drawn_water()'s own doc for why this
# three-point rule was chosen over an endpoints-only test.
if _segment_touches_drawn_water(from_district, to_district, ctx):
continue
var from_p: Vector2 = AtlasWindowGeometry.district_to_canvas_local(
from_district, ctx["held_center"], ctx["held_n"], ctx["cell_px"]
)
var to_p: Vector2 = AtlasWindowGeometry.district_to_canvas_local(
to_district, ctx["held_center"], ctx["held_n"], ctx["cell_px"]
)
var width: float = AtlasWindowGeometryNature.RIVER_DOT_RADIUS_BY_CLASS_REGION.get(cls, 2.2)
draw_line(from_p, to_p, COLOR_GEN_RIVER, _zs_stroke(width, ctx))
## T-1172 clip rule for a CHORD SEGMENT (as opposed to a single point, which
## is what the pre-T-1170 dot-scatter clipped): tested at the segment's TWO
## ENDPOINTS AND its MIDPOINT, clipping the whole segment if ANY of those
## three samples resolves to drawn water. **Decision, documented per the
## ruling's ask ("pick the visually cleaner rule, document it, test it"):**
## endpoints-only was rejected because a chord that DIPS through a coastal
## composite cell without either endpoint landing in it (a river cell just
## inland connecting to a river cell just inland on the OTHER side of a
## narrow drawn-water inlet/bay) would draw a visible line segment crossing
## open water with neither end clipped — worse than the old dot-scatter's
## per-point clip, which never had this failure mode since a dot has no
## extent to cross anything. Midpoint-only was rejected symmetrically: a
## long chord whose midpoint happens to land on drawn land while both real
## endpoints sit in drawn water would draw an uncllipped segment starting and
## ending in the ocean. Three-point (both ends + midpoint) catches the
## common cases of both failure modes at negligible extra cost (one more
## _is_drawn_water() lookup per segment) without requiring a full
## segment-rasterization walk — chords at Region spacing (~76 km apart) are
## short enough relative to Region's own 204.8 km composite cell that a
## single midpoint sample is a reasonable proxy for "does this segment pass
## through this cell", matching the coarseness the Region rung already draws
## at everywhere else in this file (204.8 km cells, not sub-cell precision).
func _segment_touches_drawn_water(from_district: Vector2, to_district: Vector2, ctx: Dictionary) -> bool:
if _is_drawn_water(from_district, ctx):
return true
if _is_drawn_water(to_district, ctx):
return true
var mid_district: Vector2 = (from_district + to_district) * 0.5
return _is_drawn_water(mid_district, ctx)
## Double-ring sea-terminus marker — verbatim geometry from the retired
## atlas_marker_overlay.gd _draw_gen_rivers() (:536-539). Mouths never fade
## (Araminta's ruling: "a mouth is always a landmark") — same styling at
## every rung it's visible at (Region, District; never Quarter). T-1170:
## also the marker for REAL course termini (Ruling 5b/3e) — one geometry
## function, both presentation surfaces.
func _draw_mouth(p: Vector2, ctx: Dictionary) -> void:
var ring_stroke: float = _zs_stroke(1.5, ctx)
draw_arc(
p,
_zs_ring_radius(AtlasWindowGeometryNature.MOUTH_RING_RADIUS, ring_stroke, ctx),
0.0,
TAU,
18,
COLOR_GEN_MOUTH,
ring_stroke
)
var halo := Color(
COLOR_GEN_MOUTH.r,
COLOR_GEN_MOUTH.g,
COLOR_GEN_MOUTH.b,
AtlasWindowGeometryNature.MOUTH_HALO_ALPHA
)
var halo_stroke: float = _zs_stroke(1.0, ctx)
draw_arc(
p,
_zs_ring_radius(AtlasWindowGeometryNature.MOUTH_HALO_RADIUS, halo_stroke, ctx),
0.0,
TAU,
22,
halo,
halo_stroke
)
## Basins — Region only, binary (no fade), per the ruling. Polygon fill +
## boundary polyline, verbatim geometry from the retired
## atlas_marker_overlay.gd _draw_gen_basins() (:542-557), coordinate mapping
## replaced with _pos() (this file's wrap-aware canvas-local mapping) in place
## of the retired _gen_pos() texture-fraction mapping. The FILL polygon's
## points are positions (never zoom-compensated — the fill must track the
## real district-space shape); only the boundary LINE's width is a
## screen-space marker size and goes through _zs().
func _draw_basins(ctx: Dictionary) -> void:
for b: Variant in _layer1.get("drainage_basins", []):
if not b is Dictionary:
continue
var boundary: Array = b.get("boundary", [])
var pts: PackedVector2Array = PackedVector2Array()
for pt: Variant in boundary:
if pt is Array and pt.size() >= 2:
pts.append(_pos(float(pt[0]), float(pt[1]), ctx))
if pts.size() < 2:
continue
if pts.size() >= 3:
draw_colored_polygon(pts, COLOR_GEN_BASIN_FILL)
var loop: PackedVector2Array = pts.duplicate()
loop.append(pts[0])
draw_polyline(loop, COLOR_GEN_BASIN_LINE, _zs_stroke(0.8, ctx), true)
## Attractors — Region only, wave 1 (per the ruling; District/Quarter never
## reach this function since _draw() gates the whole call on
## attractors_visible_at_rung()). Ported from the retired
## atlas_marker_overlay.gd _draw_gen_attractors()/_draw_attractor_shape()
## (:560-...) — the shape vocabulary (7 attractor-type glyphs) is Araminta's
## existing design, unchanged; only the coordinate mapping moves to _pos()
## and the size is zoom-compensated before reaching the shape drawer (that
## function stays a pure "draw this size at this position", zoom-agnostic).
func _draw_attractors(ctx: Dictionary) -> void:
for a: Variant in _layer1.get("attractors", []):
if not a is Dictionary:
continue
var strength: float = float(a.get("strength", 0.0))
if strength < AtlasWindowGeometryNature.ATTRACTOR_MIN_STRENGTH:
continue
var pos_rc: Variant = a.get("position")
if not pos_rc is Array or pos_rc.size() < 2:
continue
var p: Vector2 = _pos(float(pos_rc[0]), float(pos_rc[1]), ctx)
var size: float = _zs(5.0 + strength * 4.0, ctx)
var color: Color = AtlasOverlayColors.sub_biome_color(str(a.get("sub_biome", "")))
_draw_attractor_shape(str(a.get("attractor_type", "")), p, size, color, _zs_stroke(1.0, ctx))
## Attractor type -> marker shape — from the retired atlas_marker_overlay.gd
## _draw_attractor_shape(). `size` AND `px_w` (the 1-screen-px stroke unit)
## both arrive ALREADY zoom-compensated from _draw_attractors() — this
## function stays a pure "draw at these literal dimensions" primitive with no
## ctx/zoom knowledge of its own. px_w exists because Godot multiplies stroke
## WIDTH args by the canvas scale exactly like radii (PR #195 review, Tyre
## I1: the retired code's raw 1.0/2.0 widths rasterized at ~0.01px at the
## Region orbital fit zoom — the same sub-pixel failure the dot/ring
## compensation fixed, missed on glyph outlines).
func _draw_attractor_shape(
atype: String, pos: Vector2, size: float, color: Color, px_w: float
) -> void:
match atype:
"RiverMouth":
draw_circle(pos, size, color)
"Confluence":
draw_circle(pos, size * 0.8, color)
draw_arc(pos, size * 1.3, 0.0, TAU, 12, color, px_w)
"Alpine", "PassEntrance":
var pts := PackedVector2Array(
[
pos + Vector2(0, -size),
pos + Vector2(-size * 0.8, size * 0.6),
pos + Vector2(size * 0.8, size * 0.6),
]
)
draw_colored_polygon(pts, color)
"Coastal", "NaturalHarbor":
draw_arc(pos, size, PI * 0.15, PI * 0.85, 10, color, 2.0 * px_w)
"Oasis":
draw_circle(pos, size * 0.5, color)
for i in range(6):
var ang: float = TAU * float(i) / 6.0
draw_line(pos, pos + Vector2(cos(ang), sin(ang)) * size, color, px_w)
_:
draw_circle(pos, size * 0.6, color)
@@ -1,600 +0,0 @@
class_name AtlasWindowOverlay
extends Node2D
## Draws the DistrictWindowLayer composite for AtlasWindowViewer (T-1138,
## D-226 T-1124 amendment §5). Child of AtlasWindowViewer._canvas so it
## inherits the pan transform (zoom is client-side texture zoom on the
## already-held composite, §5 — never a re-fetch).
##
## Draw order (bottom to top), matching the amendment's compositing model:
## 1. Base layer — morphology hue, lightness-modulated by elev_q. Always on,
## no toggle id (§5: "it IS this screen's terrain layer").
## 2. Toggle overlays (mutually independent, at most one drawn per cell —
## each REPLACES the base read for that cell rather than blending, so
## switching between temp/moisture/veg never fights the base hue):
## gen_dw_temp / gen_dw_moisture / gen_dw_veg.
## 3. Glaciation ice-tint MODIFIER — composited over whichever layer is
## showing (base or a toggle), always-on, not a toggle id of its own.
##
## Reads window data via viewer.get_district_window() (a Dictionary or null) — this
## overlay draws nothing until the viewer has a window (border-fade during
## the wait is the VIEWER's job, drawn separately underneath this node, not
## here — this node is purely "draw the composite when there is one").
##
## T-1145 item 3 (interim presentation, pending the T-1143 design pass):
## COMPOSITE_SMOOTH := true renders the composite as an n x n Image (one
## pixel per district, EXACT same per-cell color pipeline this file always
## had — _cell_color()/_apply_glaciation() are UNCHANGED) converted to an
## ImageTexture and drawn scaled with texture-filtered sampling, instead of
## n*n flat draw_rect() calls. The crisp per-cell rect path SURVIVES behind
## the const (COMPOSITE_SMOOTH := false) so T-1143's design pass can compare
## both renderings directly — this is explicitly an INTERIM presentation, not
## the final answer on district-tier legibility (T-1143 owns that design).
##
## T-1161 (Araminta's per-rung filter ruling, PR #192 review follow-up): the
## smoothed path's PIPELINE (texture-vs-flat-rects) and its SAMPLING FILTER
## (how the GPU reads that texture) are now two INDEPENDENT axes, not one
## bundled choice:
## - Axis 1 — PIPELINE: COMPOSITE_SMOOTH (compile-time const, unchanged by
## this ticket). true = draw a texture; false = per-cell draw_rect(). The
## composite is a TEXTURE at every rung when COMPOSITE_SMOOTH is true —
## this axis does not vary per rung.
## - Axis 2 — FILTER: _filter_for_granularity_v2() (runtime, keyed on rung
## IDENTITY via granularity_v2, T-1161). Region (incl. the orbital tile
## mosaic) samples TEXTURE_FILTER_NEAREST — GPU bilinear stretch at
## 204.8 km/cell reads as a near-featureless soft gradient, technically
## honest LoD but visually indistinguishable from the coarse-composite
## smoothing-over-absence the mandate was written to kill (T-1161's own
## description). District and Quarter sample TEXTURE_FILTER_LINEAR — cell
## density there reads as texture, not smoothing-over-absence, so the
## bilinear blend is earned. No hysteresis, no px-per-cell threshold —
## the filter is a pure function of which rung's data is being drawn.
## The crisp draw_rect() path has no sampling-filter concept at all (no
## texture involved) — its comparison/debug role per the paragraph above is
## unaffected by this axis.
##
## The texture is REBUILT only when its inputs change (the window object
## itself — a new DistrictWindowLayer arriving is a new Dictionary, checked
## by REFERENCE via is_same(), not a per-field deep compare — or the active
## toggle overlay id), not per frame/per redraw. Panning and zooming redraw
## this node constantly (every _apply_transform() call) but never touch
## window/overlay state, so the common case (panning within an already-held
## window) is zero rebuild cost — draw_texture_rect() on an already-built
## ImageTexture, same as any other texture draw.
##
## T-1152/T-1153: `n` (the response's echoed district extent) and the DERIVED
## cell-grid side length are now DIFFERENT quantities at every rung except
## District — cell_grid_side_for_window() computes the latter from `n` and
## the response's own `granularity_v2` echo (mirroring the server's
## `WindowGranularity::cell_grid_side` exactly), so a Region-rung response (a
## FAR SPARSER cell grid than its district extent — see that Rust doc's
## "inversion" note) renders through the exact same colorizer pipeline as
## District/Quarter, satisfying the design doc §6 "one colorizer family, no
## per-rung palettes" encoding-continuity requirement — no branch in
## _cell_color()/_temp_cell_color()/etc. below needed any change at all.
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
const REGION_TEMP_NONE_DC: int = AtlasOverlayColors.REGION_TEMP_NONE_DC
const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd")
# T-1153, live round 3: TILE_N (the per-tile district extent) for the mosaic draw path.
const AtlasWindowGeometryRef := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
# Live round 5: `cols` (circumference in districts) for the mosaic's wrap-image draw fix.
const AtlasDescendGeometryRef := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd")
## T-1145 item 3: interim presentation toggle — true renders the smoothed
## Image/ImageTexture composite; false keeps the original crisp per-cell
## draw_rect() path (both call the SAME _cell_color()/_apply_glaciation()
## pipeline, so switching this never changes WHAT color a cell reads, only
## HOW it's rendered). Left as a compile-time const, not a runtime toggle —
## T-1143's design pass is expected to pick a winner, not ship a player-
## facing switch between them.
const COMPOSITE_SMOOTH: bool = true
## Moisture ramp reuses SUB_BIOME_COLORS' dry-sand->wet-teal ENDPOINTS (§5) —
## not the categorical lookup itself (that's keyed by sub-biome NAME, not a
## 0-100 quantity). Endpoints pulled from the existing dry/wet entries in that
## table: Desert (arid/dry) and TropicalWet (wet/coastal).
const COLOR_MOISTURE_DRY: Color = Color(0.78, 0.62, 0.35, 1.0) # sand — matches SUB_BIOME_COLORS.Desert
const COLOR_MOISTURE_WET: Color = Color(0.25, 0.72, 0.65, 1.0) # teal — matches SUB_BIOME_COLORS.TropicalWet
var viewer = null # AtlasWindowViewer (untyped to avoid cyclic ref)
## T-1145 item 3: texture rebuild cache — see the class doc's "REBUILT only
## when its inputs change" paragraph. _cache_window_ref is compared by
## REFERENCE (is_same()), not value — a fresh DistrictWindowLayer response is
## always a NEW Dictionary object (built by atlas_map_protocol.gd's decode),
## so reference identity is both correct AND far cheaper than a deep compare
## of a potentially-4096-cell dictionary on every _draw() call.
var _cached_texture: ImageTexture = null
var _cache_window_ref: Variant = null
var _cache_active_toggle: String = ""
## Live round 4 fix: per-TILE texture cache, keyed by tile index — mirrors
## the single-window cache above, but one slot per mosaic tile (a Dictionary
## of `{window_ref, active_toggle, texture}`, since the mosaic doesn't have a
## single fixed set of tiles the way the single-window path has a single
## fixed field). Building a brand-new, UNSTORED `ImageTexture` every
## `_draw()` call (the round-3 version) left it referenced only by a local
## variable — nothing keeps the RID alive past the function returning, which
## raced against the RenderingServer's deferred draw-command flush and
## rendered as a blank/white tile (the round-4 "pitch black"/white-mosaic
## repro's second half, beyond the coordinate fix above): the CPU-side pixel
## data was provably correct (sampled directly), but the GPU-side texture
## backing it could be gone by composite time. Caching each tile's texture
## as a class-owned Dictionary entry (same reference-identity rebuild-only-
## on-change discipline as `_cached_texture`) keeps it alive exactly as long
## as the single-window composite's own texture already is.
var _tile_texture_cache: Dictionary = {}
func _draw() -> void:
if viewer == null:
return
if viewer.is_tile_mode():
_draw_tile_mosaic()
return
var window: Variant = viewer.get_district_window()
if not window is Dictionary:
return
var w: Dictionary = window
var n: int = int(w.get("n", 0))
if n <= 0:
return
var morphology: Variant = w.get("morphology")
if not (morphology is PackedByteArray or morphology is Array):
return
# T-1152/T-1153: `n` (the response's echoed field) is ALWAYS window extent
# in DISTRICTS at every rung (Dudley's wire contract, DistrictWindowLayer.n's
# own doc) — the per-cell arrays (morphology/elev_q/etc.) are sized by the
# DERIVED cell-grid side, `cell_grid_side_for_window()` below, which equals
# `n` only at District granularity. Quarter packs MORE cells into the same
# n-district extent (`n*4`); Region packs FEWER, since one region cell
# spans 100 districts (`round(n/100).max(1)`). The on-screen EXTENT stays
# `n * cell_px` regardless of rung (CELL_PIXEL_SIZE is defined as "one
# DISTRICT at zoom=1.0" — see the viewer's own doc on that constant) so a
# rung swap at a fixed pan/zoom never jumps the composite's screen footprint
# (§6 "no layout jump") — only the TEXTURE RESOLUTION packed into that
# footprint changes, exactly the "same colorizer family, different LoD"
# picture the design doc describes.
var grid_side: int = cell_grid_side_for_window(w)
if grid_side <= 0:
return
var cell_px: float = viewer.get_cell_pixel_size()
var active_toggle: String = _active_toggle_overlay()
if COMPOSITE_SMOOTH:
_draw_smoothed_composite(w, n, grid_side, cell_px, active_toggle)
else:
_draw_crisp_composite(w, grid_side, n, cell_px, active_toggle)
## T-1153, live round 3/4 (Jeroen's ruling, design doc §4): the orbital
## rest-state MOSAIC draw path — one call to the EXISTING single-tile
## composite-building logic (`_rebuild_texture_if_needed()`/
## `_draw_smoothed_composite()`'s own per-tile equivalent below) PER TILE,
## each positioned at its own LOCAL offset in the SAME canvas-local
## coordinate frame the single-window path (and fit_window_view()/
## screen_center_to_district()) already use.
##
## **Live round 4 fix:** the round-3 version placed tiles relative to
## absolute district (0,0) directly (`(tile.center - TILE_N/2) * cell_px`),
## which does NOT match `_fit_and_center()`'s own convention — canvas-local
## (0,0) is `held_center - held_n/2` (AtlasWindowGeometry.
## district_to_canvas_local()'s own doc), and in tile mode `held_n` is the
## WHOLE BODY's extent, not TILE_N. That mismatch pushed the entire mosaic
## off-canvas (round 4's "pitch black" repro) — silent, since nothing
## errors, it just draws somewhere the viewport never shows. Fixed by
## routing every tile's placement through `district_to_canvas_local()` with
## the VIEWER's own `held_center`/`held_n`, the exact reference frame every
## other canvas-local consumer (fit/pan/reselect) already agrees on. Tiles
## that haven't arrived yet (`tile["window"] == null`) are simply SKIPPED —
## no per-tile placeholder draw, letting COLOR_BG show through as the honest
## "nothing here yet" read (the viewer's own `_draw()` already documents why
## no separate whole-viewport fade is needed on top of this).
##
## **Live round 5 fix:** `tile["center"]` is CANONICAL (wrapped into
## `[0, cols)` by `compute_tile_grid()` — correct for REQUESTS/cache keys,
## since longitude is periodic and a canonical column is the single-valued
## key both sides of the wire agree on). But `district_to_canvas_local()`
## is a pure LINEAR function with no wrap concept — handed a canonical
## column directly, it places the tile at exactly ONE of its infinitely
## many equivalent on-screen positions (`col + k*cols`), which is only the
## visually-correct one by coincidence. Lendel's own repro: the tile whose
## true position is immediately WEST of the canonical origin canonicalizes
## to column 12739 (`-6400 mod 19139`) — drawn there directly, it lands
## off-canvas RIGHT, leaving the mosaic's actual LEFT third black. Fixed by
## re-expressing each tile's column via `nearest_wrap_image()` — whichever
## wrap-image is closest to `held_center`, i.e. the one actually near the
## current view — BEFORE handing it to `district_to_canvas_local()`.
func _draw_tile_mosaic() -> void:
var tile_set = viewer.get_tile_set()
if tile_set == null:
return
var cell_px: float = viewer.get_cell_pixel_size()
var active_toggle: String = _active_toggle_overlay()
var held_center: Vector2i = viewer.get_held_center()
var held_n: int = viewer.get_held_n()
var half_tile: float = float(AtlasWindowGeometryRef.TILE_N) * 0.5
var tiles: Array = tile_set.get_tiles()
var cols: int = int(
AtlasDescendGeometryRef.district_extent(viewer.get_body_radius_km()).get("cols", 0)
)
for i in range(tiles.size()):
var tile: Dictionary = tiles[i]
var window: Variant = tile["window"]
var center: Vector2i = tile["center"]
var draw_col: int = AtlasWindowGeometryRef.nearest_wrap_image(center.x, held_center.x, cols)
var tile_top_left: Vector2 = Vector2(
float(draw_col) - half_tile, float(center.y) - half_tile
)
var local_origin: Vector2 = AtlasWindowGeometryRef.district_to_canvas_local(
tile_top_left, held_center, held_n, cell_px
)
var extent: float = float(AtlasWindowGeometryRef.TILE_N) * cell_px
# Coordinator ask (cold-start dossier): a bare COLOR_BG gap for an
# unarrived tile reads as broken, not "still working" — a cold
# server's first AnalyzeBody can take seconds, during which every
# tile in the mosaic is exactly this state at once. Same treatment
# the single-window path already gives its OWN no-composite-yet wait
# (AtlasWindowViewer._draw_border_fade()) — read off the live
# `viewer` instance rather than a preload of its script (that field
# is deliberately untyped to avoid a cyclic ref, see its own doc; a
# const is reachable through an instance either way).
if not window is Dictionary:
draw_rect(Rect2(local_origin, Vector2(extent, extent)), viewer.COLOR_BORDER_FADE)
continue
var w: Dictionary = window
var morphology: Variant = w.get("morphology")
if not (morphology is PackedByteArray or morphology is Array):
continue
var grid_side: int = cell_grid_side_for_window(w)
if grid_side <= 0:
continue
_draw_one_tile(i, w, grid_side, local_origin, extent, active_toggle)
## One tile's own composite — the SAME crisp/smoothed per-cell pipeline the
## single-window path uses (_cell_color()/_apply_glaciation(), UNCHANGED),
## just drawn at `local_origin` instead of always at (0,0). Each tile gets
## its OWN texture-rebuild cache slot in `_tile_texture_cache`, keyed by
## `tile_index` — sharing ONE `_cached_texture` slot across all tiles (the
## single-window field) would thrash on every draw call as different tiles'
## windows compete for it.
##
## T-1161: every mosaic tile is a Region-rung request (atlas_window_tile_set.gd
## requests tiles at AtlasWindowRequest.GRANULARITY_V2_REGION), so the mosaic
## as a whole is in scope for the Region -> NEAREST ruling. As with the
## single-window path, the filter is read from THIS tile's own echoed `w`
## rather than assumed, via the shared `_filter_for_granularity_v2()` helper
## — one policy, two call sites, no duplicated match statement.
func _draw_one_tile(
tile_index: int,
w: Dictionary,
grid_side: int,
local_origin: Vector2,
extent: float,
active_toggle: String
) -> void:
if not COMPOSITE_SMOOTH:
_draw_crisp_tile(w, grid_side, local_origin, extent, active_toggle)
return
var tile_texture: ImageTexture = _rebuild_tile_texture_if_needed(
tile_index, w, grid_side, active_toggle
)
if tile_texture == null:
return
var granularity_v2 := str(w.get("granularity_v2", AtlasWindowRequest.GRANULARITY_V2_DISTRICT))
texture_filter = _filter_for_granularity_v2(granularity_v2)
draw_texture_rect(tile_texture, Rect2(local_origin, Vector2(extent, extent)), false)
## Live round 4 fix: rebuilds (and, critically, KEEPS — see
## `_tile_texture_cache`'s own doc for why an unstored local `ImageTexture`
## silently rendered blank/white) `_tile_texture_cache[tile_index]`'s texture
## ONLY when that tile's window object or the active toggle overlay has
## changed since the last build — the SAME reference-identity discipline
## `_rebuild_texture_if_needed()` uses for the single-window composite, one
## cache entry per tile index instead of one shared field.
func _rebuild_tile_texture_if_needed(
tile_index: int, w: Dictionary, grid_side: int, active_toggle: String
) -> ImageTexture:
var entry: Dictionary = _tile_texture_cache.get(tile_index, {})
if (
is_same(entry.get("window_ref"), w)
and entry.get("active_toggle") == active_toggle
and entry.get("texture") != null
):
return entry["texture"]
var texture: ImageTexture = _build_tile_texture(w, grid_side, active_toggle)
_tile_texture_cache[tile_index] = {
"window_ref": w, "active_toggle": active_toggle, "texture": texture
}
return texture
## Builds a tile's own Image/ImageTexture from its per-cell colors —
## identical pipeline to `_rebuild_texture_if_needed()`, just returning the
## texture directly instead of writing to the single-window cache fields
## (the CALLER, `_rebuild_tile_texture_if_needed()`, owns persisting it).
func _build_tile_texture(w: Dictionary, grid_side: int, active_toggle: String) -> ImageTexture:
var elev_q: Variant = w.get("elev_q")
var glaciation: Variant = w.get("glaciation")
var morphology: Variant = w.get("morphology")
var n_cells: int = morphology.size()
var img := Image.create(grid_side, grid_side, false, Image.FORMAT_RGBA8)
for row in range(grid_side):
for col in range(grid_side):
var i: int = row * grid_side + col
if i >= n_cells:
img.set_pixel(col, row, Color.TRANSPARENT)
continue
var cell_color: Color = _cell_color(w, i, int(morphology[i]), elev_q, active_toggle)
cell_color = _apply_glaciation(cell_color, glaciation, i)
img.set_pixel(col, row, cell_color)
return ImageTexture.create_from_image(img)
## The crisp (non-smoothed) per-tile path — mirrors `_draw_crisp_composite()`
## exactly, just positioned at `local_origin` instead of always at (0,0).
func _draw_crisp_tile(
w: Dictionary, grid_side: int, local_origin: Vector2, extent: float, active_toggle: String
) -> void:
var elev_q: Variant = w.get("elev_q")
var glaciation: Variant = w.get("glaciation")
var morphology: Variant = w.get("morphology")
var n_cells: int = morphology.size()
var screen_cell_px: float = extent / float(grid_side)
for row in range(grid_side):
for col in range(grid_side):
var i: int = row * grid_side + col
if i >= n_cells:
continue
var cell_color: Color = _cell_color(w, i, int(morphology[i]), elev_q, active_toggle)
if cell_color.a <= 0.0:
continue
cell_color = _apply_glaciation(cell_color, glaciation, i)
var cell_origin: Vector2 = local_origin + Vector2(col * screen_cell_px, row * screen_cell_px)
draw_rect(
Rect2(cell_origin, Vector2(screen_cell_px + 0.5, screen_cell_px + 0.5)), cell_color
)
## The derived cell-grid side length (in CELLS) for a window dict `w` —
## mirrors server/src/atlas/layer_proxy.rs's `WindowGranularity::cell_grid_side`
## exactly, reading `w`'s OWN echoed `n`/`granularity_v2` fields rather than
## trusting a caller's separately-tracked rung (the response is the source of
## truth for what it actually contains). Falls back to `n` unchanged
## (District's own identity mapping) for an old-shape response with no
## `granularity_v2` key — matches the server's own "unknown -> District"
## posture and AtlasWindowRequest.on_response()'s own default-to-District
## disposition for the same field.
static func cell_grid_side_for_window(w: Dictionary) -> int:
var n: int = int(w.get("n", 0))
var granularity_v2 := str(w.get("granularity_v2", AtlasWindowRequest.GRANULARITY_V2_DISTRICT))
match granularity_v2:
AtlasWindowRequest.GRANULARITY_V2_QUARTER:
return n * 4 # WINDOW_GRANULARITY_QUARTER multiplier — D-243 QUARTER_M
AtlasWindowRequest.GRANULARITY_V2_REGION:
return maxi(roundi(float(n) / 100.0), 1) # D-243 DISTRICTS_PER_REGION
_:
return n # District — 1:1
## T-1161 (Araminta's per-rung filter ruling): the sampling filter to use for
## the smoothed composite's texture, keyed on RUNG IDENTITY alone via
## `granularity_v2` — no hysteresis, no px-per-cell/zoom threshold. Region
## (204.8 km/cell — the same rung the orbital tile mosaic draws at, since
## every mosaic tile is itself a Region-rung window per `_draw_tile_mosaic()`)
## reads NEAREST: at that density, GPU bilinear blending between real derived
## samples is technically honest LoD but visually indistinguishable from the
## coarse-composite-stretched smoothing-over-absence the mandate was written
## to kill — the spirit is violated even though the letter ("never magnified
## interpolation") is not. District and Quarter read LINEAR: cell density at
## those rungs is high enough that the blend reads as texture, not as papering
## over sparse data. Mirrors `cell_grid_side_for_window()`'s own posture on an
## unknown/missing tag — an unrecognized wire value must never be trusted into
## the crisp NEAREST treatment, so it falls back to District's LINEAR instead
## of Region's NEAREST (fail toward the safer/already-shipped look).
static func _filter_for_granularity_v2(granularity_v2: String) -> CanvasItem.TextureFilter:
match granularity_v2:
AtlasWindowRequest.GRANULARITY_V2_REGION:
return CanvasItem.TEXTURE_FILTER_NEAREST
_:
return CanvasItem.TEXTURE_FILTER_LINEAR # District, Quarter, and unknown/missing fallback
## T-1145 item 3: the smoothed path — build/reuse a `grid_side` x `grid_side`
## ImageTexture (one pixel per DERIVED CELL, T-1152 — not per district, see
## cell_grid_side_for_window()'s doc) and draw it scaled to (n*cell_px), n
## being the window's DISTRICT extent. texture_filter is set on `self` (a
## CanvasItem property) once per draw — cheap (a property write, not a
## texture rebuild). T-1161: the filter itself is now PER-RUNG, read from
## `w`'s own echoed `granularity_v2` (the same "response is the source of
## truth" posture cell_grid_side_for_window() already uses) via
## `_filter_for_granularity_v2()`, rather than an unconditional LINEAR.
func _draw_smoothed_composite(
w: Dictionary, n: int, grid_side: int, cell_px: float, active_toggle: String
) -> void:
var granularity_v2 := str(w.get("granularity_v2", AtlasWindowRequest.GRANULARITY_V2_DISTRICT))
texture_filter = _filter_for_granularity_v2(granularity_v2)
_rebuild_texture_if_needed(w, grid_side, active_toggle)
if _cached_texture == null:
return
var extent: float = float(n) * cell_px
draw_texture_rect(_cached_texture, Rect2(0.0, 0.0, extent, extent), false)
## Rebuilds _cached_texture from `w`'s per-cell colors ONLY when the window
## object or the active toggle overlay has changed since the last build —
## see the class doc's rebuild-cost paragraph. `elev_q` and `glaciation` are
## read directly from `w` here (rather than threaded through as params, the
## way the crisp path's _cell_color()/_apply_glaciation() calls already
## receive them) since this function owns the whole per-cell loop, not just
## one cell. `grid_side` (T-1152) is the DERIVED cell-grid side (see
## cell_grid_side_for_window()), not the window's district extent `n`.
func _rebuild_texture_if_needed(w: Dictionary, grid_side: int, active_toggle: String) -> void:
if (
is_same(_cache_window_ref, w)
and _cache_active_toggle == active_toggle
and _cached_texture != null
):
return # inputs unchanged since the last build — reuse the existing texture
var elev_q: Variant = w.get("elev_q")
var glaciation: Variant = w.get("glaciation")
var morphology: Variant = w.get("morphology")
var n_cells: int = morphology.size()
var img := Image.create(grid_side, grid_side, false, Image.FORMAT_RGBA8)
for row in range(grid_side):
for col in range(grid_side):
var i: int = row * grid_side + col
if i >= n_cells:
img.set_pixel(col, row, Color.TRANSPARENT)
continue
var cell_color: Color = _cell_color(w, i, int(morphology[i]), elev_q, active_toggle)
cell_color = _apply_glaciation(cell_color, glaciation, i)
img.set_pixel(col, row, cell_color)
_cached_texture = ImageTexture.create_from_image(img)
_cache_window_ref = w
_cache_active_toggle = active_toggle
## The ORIGINAL crisp per-cell path — kept byte-for-byte behind
## COMPOSITE_SMOOTH := false so T-1143's design pass can compare both
## renderings directly (see the class doc). `grid_side` (T-1152) is the
## DERIVED cell-grid side (see cell_grid_side_for_window()); `n` (the
## window's district extent) sizes the on-screen cell pitch so the total
## drawn footprint stays `n * cell_px` regardless of rung.
##
## Note (T-1161): this path never touches the node-level `texture_filter`
## property — draw_rect() has no texture/sampling-filter concept, so there is
## nothing to set. That is inert today only because nothing else reads
## `texture_filter` while this path is active; it is not a bug to fix here,
## just worth stating since the smoothed path now sets that property
## per-rung and a reader might otherwise wonder why this path doesn't.
func _draw_crisp_composite(
w: Dictionary, grid_side: int, n: int, cell_px: float, active_toggle: String
) -> void:
var elev_q: Variant = w.get("elev_q")
var glaciation: Variant = w.get("glaciation")
var morphology: Variant = w.get("morphology")
var n_cells: int = morphology.size()
var screen_cell_px: float = float(n) * cell_px / float(grid_side)
for row in range(grid_side):
for col in range(grid_side):
var i: int = row * grid_side + col
if i >= n_cells:
continue
var cell_color: Color = _cell_color(w, i, int(morphology[i]), elev_q, active_toggle)
if cell_color.a <= 0.0:
continue # Marine-transparent or otherwise "don't draw" (cheaper than a 0-alpha rect)
cell_color = _apply_glaciation(cell_color, glaciation, i)
# +0.5 overdraw avoids hairline seams between adjacent cells —
# same idiom as _draw_gen_district/_draw_gen_region_grid.
draw_rect(
Rect2(col * screen_cell_px, row * screen_cell_px, screen_cell_px + 0.5, screen_cell_px + 0.5),
cell_color
)
## Which of the three mutually-exclusive toggle overlays (if any) is active.
## At most one draws — §5 does not describe blending two toggles together,
## and doing so would fight the "one colorizer, one read" legibility goal the
## whole layer design optimizes for. First-match-wins on ties (should never
## happen — the overlay bar toggles independently, but this keeps the draw
## deterministic instead of implicitly depending on dictionary iteration
## order if more than one somehow ends up true).
func _active_toggle_overlay() -> String:
if viewer.is_overlay_visible("gen_dw_temp"):
return "gen_dw_temp"
if viewer.is_overlay_visible("gen_dw_moisture"):
return "gen_dw_moisture"
if viewer.is_overlay_visible("gen_dw_veg"):
return "gen_dw_veg"
return ""
func _cell_color(
w: Dictionary, i: int, morphology_zone: int, elev_q: Variant, active_toggle: String
) -> Color:
match active_toggle:
"gen_dw_temp":
return _temp_cell_color(w.get("temp_dc"), i)
"gen_dw_moisture":
return _moisture_cell_color(w.get("moisture_q"), i)
"gen_dw_veg":
return _veg_cell_color(w.get("vegetation"), i)
_:
return _base_cell_color(morphology_zone, elev_q, i)
## Base layer: morphology hue, lightness-modulated by elev_q (§5's one
## `0.7 + 0.3*(elev_q/100)` multiply per cell).
func _base_cell_color(morphology_zone: int, elev_q: Variant, i: int) -> Color:
var base: Color = AtlasOverlayColors.district_window_morphology_color(morphology_zone)
var eq: int = _dense_int(elev_q, i, 50)
return AtlasOverlayColors.district_window_elevation_lightness(base, eq)
## gen_dw_temp: reuses T-1118's region_temp_color() EXACTLY — same i16
## deci-°C domain, same REGION_TEMP_NONE_DC sentinel disposition (skip the
## cell entirely, matching _draw_gen_region_grid's airless treatment) — one
## colorizer across both zoom levels, per the amendment's consistency ruling.
func _temp_cell_color(temp_dc: Variant, i: int) -> Color:
if temp_dc == null:
return Color.TRANSPARENT
var t: int = _dense_int(temp_dc, i, REGION_TEMP_NONE_DC)
if t == REGION_TEMP_NONE_DC:
return Color.TRANSPARENT # airless — no reading, skip the cell (matches region-grid precedent)
return AtlasOverlayColors.region_temp_color(t)
## gen_dw_moisture: dry-sand -> wet-teal ramp over the existing SUB_BIOME_COLORS
## endpoints (§5).
func _moisture_cell_color(moisture_q: Variant, i: int) -> Color:
if moisture_q == null:
return Color.TRANSPARENT
var m: int = clampi(_dense_int(moisture_q, i, 50), 0, 100)
return COLOR_MOISTURE_DRY.lerp(COLOR_MOISTURE_WET, float(m) / 100.0)
## gen_dw_veg: green-family ramp, Marine transparent (§3/§5 — non-negotiable
## per the amendment; see atlas_overlay_colors.gd's vegetation_color() doc).
func _veg_cell_color(vegetation: Variant, i: int) -> Color:
if vegetation == null:
return Color.TRANSPARENT
return AtlasOverlayColors.vegetation_color(_dense_int(vegetation, i, 0))
## Glaciation: an always-on MODIFIER (never a toggle id), composited over
## whichever layer is currently showing — the base or one of the three
## toggles (§5).
func _apply_glaciation(cell_color: Color, glaciation: Variant, i: int) -> Color:
if glaciation == null:
return cell_color
var grade: int = _dense_int(glaciation, i, 0)
return AtlasOverlayColors.glaciation_tint(cell_color, grade)
## Reads element `i` from a dense numeric array field regardless of whether
## the messagepack decode produced a PackedByteArray (u8 fields) or a plain
## Array (i16 temp_dc — rmp_serde without serde_bytes, matching the existing
## region_grid _dense_int precedent in test_atlas_overlays.gd, now needed at
## RUNTIME here too, not just in a test helper).
static func _dense_int(arr: Variant, i: int, fallback: int) -> int:
if (arr is Array or arr is PackedByteArray) and i < arr.size():
return int(arr[i])
return fallback
@@ -1,464 +0,0 @@
extends Node
## District-window request orchestration for AtlasWindowViewer (T-1138, D-226
## T-1124 amendment §1/§4). Owns the cache, the pan-triggered re-request
## policy, the post-drag-release debounce, and the retry loop for the
## queue-based background derive (PR #185 finding: a window response arrives
## on a LATER TICK, not synchronously — the exact same "None until derived,
## re-poll" contract atlas_generation_proxy.gd's Layer1 path already handles,
## reused here rather than re-invented).
##
## This script has no `class_name` on purpose, matching every other
## viewer-owned helper in this cluster (atlas_overlay_bar.gd/
## atlas_legend_panel.gd/atlas_generation_proxy.gd, review #8 precedent): the
## owner (AtlasWindowViewer) passes itself to _init(), and a `class_name` +
## required-arg _init() combo is a Godot editor footgun. `extends Node` (not
## RefCounted) because it needs get_tree() for the debounce/retry timers —
## added as a child via
## load("res://ui/implant/apps/atlas/atlas_window_request.gd").new(self).
##
## §4 policy fixed here (the constants + the debounce, NOT the pan-edge
## detection — that's the viewer's job, since it owns the screen-to-district
## geometry):
## - DISTRICT_WINDOW_DEFAULT_N = 32 (client's interactive default, half the
## server's DISTRICT_WINDOW_MAX_N = 64 hard cap — §4 pins both numbers;
## the cap itself is a server-side clamp this client never needs to
## duplicate, only stay under so a request is never silently clamped in
## a way the client didn't expect).
## - 150ms post-drag-release debounce — long enough to collapse a
## flick-and-resettle into one request, short enough that a deliberate
## single pan-and-stop never feels delayed (§4/§5 wording, identical).
## - Cache-hit is instant (no request at all) — §4's "D-227 makes exact-
## repeat the common case for Esc-then-re-enter and pan-back" is what
## makes this the common path, not the minority one.
signal window_ready(window: Dictionary) # emitted on a cache hit OR a fresh Ready response
const AtlasWindowCache := preload("res://ui/implant/apps/atlas/atlas_window_cache.gd")
const DISTRICT_WINDOW_DEFAULT_N: int = 32
const DEBOUNCE_DELAY: float = 0.15 # 150ms, §4/§5
## Cold-start dossier (PR #192 round 3): the retry-on-PENDING loop used to be
## a flat RETRY_DELAY=0.5s / MAX_RETRIES=20 (~10s ceiling), copied verbatim
## from atlas_generation_proxy.gd's Layer1 poll — a DIFFERENT, typically
## faster derive. The real starvation bug turned out to be the status-gate
## fix in on_response() (see that function's own doc) — this backoff/stagger
## work is HARDENING landed alongside it, not the fix itself: once the
## status-gate fix makes 6 independent tiles all correctly retry on a
## whole-response Pending, they do so in perfect lockstep (all six went
## pending at entry within the same frame, so all six retry timers fire
## within the same frame too) — six re-requests every RETRY_DELAY, in sync,
## is exactly the "storm" shape worth damping even though it isn't what
## caused the starvation. Exponential backoff (INITIAL_RETRY_DELAY doubling
## to MAX_RETRY_DELAY) plus a DETERMINISTIC per-tile stagger
## (STAGGER_STEP * stagger_index, set once by the owning AtlasWindowTileSet
## at construction — see `_stagger_index`) spread that pulse into a trickle:
## tile 0 retries at 0.5s, tile 1 at 0.6s, tile 2 at 0.7s, etc. — deterministic
## and directly assertable in a test, not a randomized jitter a test would
## have to tolerance-check. Backoff ALSO buys a much longer wall-clock window
## from a modest MAX_RETRIES increase (~110s at 30 retries, see
## _retry_delay_for()'s own doc) without ever polling aggressively for that
## whole span. A fast (already-warm) response still resolves on retry #1,
## unaffected — backoff/stagger only matter once a request is genuinely
## still pending past the first cycle.
const INITIAL_RETRY_DELAY: float = 0.5 # first retry, matches the old flat RETRY_DELAY
const MAX_RETRY_DELAY: float = 4.0 # backoff ceiling — never polls slower than this
const STAGGER_STEP: float = 0.1 # per-tile-index offset — tile i retries STAGGER_STEP*i later
const MAX_RETRIES: int = 30 # ~110s wall-clock at the backoff schedule above
## T-1150 struct/key plumbing: legacy int granularity — district is the
## default for every caller that doesn't request quarter/Region explicitly.
const DEFAULT_GRANULARITY: int = AtlasWindowCache.DISTRICT_GRANULARITY
const DEFAULT_MIN_WL_M: int = 0
## T-1152/T-1153: the R5-redesigned string-tag granularity — "Quarter" |
## "District" | "Region". This is the axis request_now()/request_debounced()'s
## `granularity_v2` parameter actually varies; the legacy int
## (DEFAULT_GRANULARITY) stays pinned at district for every call this object
## makes, since v2 always wins server-side once present
## (resolve_window_granularity_v2()'s documented precedence) and the legacy
## int cannot express Region at all.
const GRANULARITY_V2_QUARTER: String = AtlasWindowCache.GRANULARITY_V2_QUARTER
const GRANULARITY_V2_DISTRICT: String = AtlasWindowCache.GRANULARITY_V2_DISTRICT
const GRANULARITY_V2_REGION: String = AtlasWindowCache.GRANULARITY_V2_REGION
const DEFAULT_GRANULARITY_V2: String = AtlasWindowCache.DEFAULT_GRANULARITY_V2
## Mirrors server/src/atlas/layer_proxy.rs's DISTRICT_WINDOW_MAX_N /
## WIRE_CAP_CELLS exactly (PR #191 review, Tyre C1). `_clamp_window_n_mirror()`
## below reproduces `clamp_window_n()` bit-for-bit — the load-bearing-mirror
## pattern `AtlasDescendGeometry.canonicalize_district_center()` already uses
## for the server's `normalize_window_center()`. Keep both numbers in sync
## with the server constants of the same name if either ever changes.
const SERVER_DISTRICT_WINDOW_MAX_N: int = 64
const SERVER_WIRE_CAP_CELLS: int = 4_096
## T-1152/T-1153: mirrors server/src/atlas/layer_proxy.rs's
## `DISTRICT_WINDOW_MAX_N_REGION` — the Region-only per-axis ceiling on `n`
## (still window extent in DISTRICTS, per WindowGranularity::cell_grid_side's
## doc: `sqrt(WIRE_CAP_CELLS) * DISTRICTS_PER_REGION = 64 * 100`). Keep in
## sync with the server constant of the same name.
const SERVER_DISTRICT_WINDOW_MAX_N_REGION: int = 6_400
## Mirrors server/src/atlas/scale.rs's DISTRICTS_PER_REGION (D-243: one region
## = 100 districts/side) — the divisor `_cell_grid_side_region_mirror()` needs
## to reproduce `WindowGranularity::cell_grid_side`'s Region branch.
const SERVER_DISTRICTS_PER_REGION: int = 100
var _owner = null # AtlasWindowViewer (untyped to avoid cyclic ref)
var _cache = null # AtlasWindowCache
var _body_id: String = ""
var _center: Vector2i = Vector2i.ZERO
var _n: int = DISTRICT_WINDOW_DEFAULT_N
var _granularity: int = DEFAULT_GRANULARITY
var _granularity_v2: String = DEFAULT_GRANULARITY_V2
var _min_wl_m: int = DEFAULT_MIN_WL_M
var _pending: bool = false
var _retries: int = 0
var _debounce_timer: Timer = null
## Cold-start dossier round 3: deterministic per-request stagger index for
## the retry backoff (see STAGGER_STEP's own doc) — 0 for the single-window
## viewer's own request (no fan-out, nothing to desync from), the tile's own
## index (0..5) for a tile-set-owned request (AtlasWindowTileSet.enter()
## sets this once at construction, right after AtlasWindowRequest.new()).
var _stagger_index: int = 0
func _init(owner_ref = null) -> void:
_owner = owner_ref
_cache = AtlasWindowCache.new()
func _ready() -> void:
_debounce_timer = Timer.new()
_debounce_timer.name = "DebounceTimer"
_debounce_timer.one_shot = true
_debounce_timer.wait_time = DEBOUNCE_DELAY
_debounce_timer.timeout.connect(_on_debounce_timeout)
add_child(_debounce_timer)
## Reset for a fresh entry into the regional window mode (new body/center) —
## clears in-flight retry bookkeeping but NOT the cache (D-227: a cached
## window is valid forever regardless of which body/center the viewer is
## currently showing; clearing on every entry would throw away exactly the
## Esc-then-re-enter hit §4 promises).
func reset() -> void:
_pending = false
_retries = 0
if _debounce_timer:
_debounce_timer.stop()
## Mirrors server/src/atlas/layer_proxy.rs's `clamp_window_n(raw_n,
## granularity)` EXACTLY (PR #191 review, Tyre C1 — "the sharpest" finding):
## `serve_district_window` echoes the CLAMPED `n` back in
## `DistrictWindowLayer.n`, but `on_response()`'s staleness guard compares the
## echo against `_n`. Without this mirror, `_n` would hold the RAW requested
## value while the server echoes the CLAMPED one — the moment a caller
## requests quarter (granularity=4) at n=32, the server clamps to n=16 and
## echoes THAT, `on_response()` sees `echoed_n=16 != _n=32`, decides the
## response is stale, and the window silently never loads (no error, no log
## on this side — just an eternally-pending request).
##
## Clamping HERE, before `_n` is ever stored or sent, means `_n` already
## equals what the server will echo — no drift between the two sides, the
## SAME load-bearing-mirror pattern `AtlasDescendGeometry.
## canonicalize_district_center()` uses for the server's
## `normalize_window_center()` (see that function's docstring for the general
## rationale: canonicalizing before the request is sent means the client's
## held state already equals what the server will echo back).
##
## Formula, bit-for-bit: `n = raw_n.clamp(1, SERVER_DISTRICT_WINDOW_MAX_N)`,
## then `n = min(n, floor(sqrt(SERVER_WIRE_CAP_CELLS) / max(granularity, 1)))`
## — applied in that order (per-axis cap first, then the granularity-aware
## wire-size ceiling), matching `clamp_window_n`'s own comment ("Applied AFTER
## the per-axis clamp so a request that already satisfies
## DISTRICT_WINDOW_MAX_N still shrinks further at granularity 4").
static func _clamp_window_n_mirror(raw_n: int, granularity: int) -> int:
var n: int = clampi(raw_n, 1, SERVER_DISTRICT_WINDOW_MAX_N)
var g: int = maxi(granularity, 1)
var cap_n: int = int(floor(sqrt(float(SERVER_WIRE_CAP_CELLS)) / float(g)))
return mini(n, maxi(cap_n, 1))
## [`WindowGranularity`]-aware twin of `_clamp_window_n_mirror()` (T-1152/
## T-1153) — mirrors server/src/atlas/layer_proxy.rs's `clamp_window_n_v2`
## EXACTLY, including its Region branch, per the ticket's explicit
## instruction ("replicate the loop exactly, there is NO closed form"). For
## District/Quarter this delegates straight to `_clamp_window_n_mirror()`
## (byte-identical clamped `n`, matching the server's own
## `clamp_window_n_v2_matches_legacy_for_finer_than_district_rungs`
## guarantee). For Region: per-axis clamp to
## `SERVER_DISTRICT_WINDOW_MAX_N_REGION` (6,400), then halve `n` in a bounded
## loop while `_cell_grid_side_region_mirror(n)^2 > SERVER_WIRE_CAP_CELLS` and
## `n > 1` — there is no closed-form inverse of the rounding division
## `cell_grid_side` uses at Region granularity, so this loop is the correct
## (and only) mirror, not an approximation of one.
static func _clamp_window_n_mirror_v2(raw_n: int, granularity_v2: String) -> int:
if granularity_v2 != AtlasWindowCache.GRANULARITY_V2_REGION:
var legacy_granularity: int = (
DEFAULT_GRANULARITY
if granularity_v2 == AtlasWindowCache.GRANULARITY_V2_DISTRICT
else AtlasWindowCache.DISTRICT_GRANULARITY * 4 # "Quarter" — WINDOW_GRANULARITY_QUARTER
)
return _clamp_window_n_mirror(raw_n, legacy_granularity)
var n: int = clampi(raw_n, 1, SERVER_DISTRICT_WINDOW_MAX_N_REGION)
while (
_cell_grid_side_region_mirror(n) * _cell_grid_side_region_mirror(n) > SERVER_WIRE_CAP_CELLS
and n > 1
):
n = int(n / 2.0)
return maxi(n, 1)
## Mirrors `WindowGranularity::cell_grid_side`'s Region branch EXACTLY:
## `round(n / DISTRICTS_PER_REGION).max(1)` — the derived region-cell-grid
## side length (in CELLS) for a window whose extent is `n` DISTRICTS. Rust's
## `f64::round()` is round-half-away-from-zero; GDScript's `roundi()` matches
## that for non-negative inputs (the only domain `n` — always >= 1 here —
## can produce), so this is a faithful mirror, not an approximation.
static func _cell_grid_side_region_mirror(n: int) -> int:
var side: int = roundi(float(n) / float(SERVER_DISTRICTS_PER_REGION))
return maxi(side, 1)
## Entry point + pan re-request: request the window centered on `center`
## (a DistrictPos-equivalent Vector2i) for `body_id`, at `granularity_v2`
## ("Quarter" | "District" | "Region", T-1152/T-1153 — District is the
## default for every caller that doesn't ask for a different rung explicitly,
## matching the legacy behavior byte-for-byte when omitted). Cache hit ->
## immediate synchronous window_ready emit, no network traffic at all. Cache
## miss -> fire the request now (the caller — either the initial entry or a
## debounce-fired pan/zoom — has already decided this call SHOULD fire; the
## 150ms debounce itself lives in request_debounced() below, not here, so
## this function is also the one entry-mechanic click-through uses directly
## with no debounce at all, matching §5's "first window" contract).
func request_now(
body_id: String,
center: Vector2i,
n: int = DISTRICT_WINDOW_DEFAULT_N,
granularity_v2: String = DEFAULT_GRANULARITY_V2
) -> void:
_body_id = body_id
_center = center
_granularity = DEFAULT_GRANULARITY # legacy int stays pinned at district — v2 always wins server-side
_granularity_v2 = granularity_v2
_min_wl_m = DEFAULT_MIN_WL_M
_n = _clamp_window_n_mirror_v2(n, _granularity_v2) # Tyre C1, extended T-1152 — mirror BEFORE storing
_debounce_timer.stop() # a direct request supersedes any pending debounced one
var cached: Variant = _cache.get_window(
body_id, center, _n, _granularity, _min_wl_m, _granularity_v2
)
if cached != null:
_pending = false
_retries = 0
window_ready.emit(cached)
return
_pending = true
_retries = 0
SimBridge.request_atlas_layers(
body_id, "Topography", center, _n, _granularity, _min_wl_m, _granularity_v2
)
## Pan-triggered re-request (§4/§5: "150ms after the last drag-release, not
## per-drag-frame"). The viewer calls this on every pan-edge-crossing
## candidate; only the LAST call within the debounce window actually fires
## (Timer.start() on an already-running one-shot timer restarts it — Godot's
## documented behavior — so a flick-and-resettle collapses to one request).
func request_debounced(
body_id: String,
center: Vector2i,
n: int = DISTRICT_WINDOW_DEFAULT_N,
granularity_v2: String = DEFAULT_GRANULARITY_V2
) -> void:
_body_id = body_id
_center = center
_granularity = DEFAULT_GRANULARITY
_granularity_v2 = granularity_v2
_min_wl_m = DEFAULT_MIN_WL_M
_n = _clamp_window_n_mirror_v2(n, _granularity_v2) # Tyre C1, extended T-1152 — mirror BEFORE storing
_debounce_timer.start()
func _on_debounce_timeout() -> void:
request_now(_body_id, _center, _n, _granularity_v2)
## Handle an AtlasLayerResponse (routed by the owning viewer from its own
## SimBridge.atlas_layers_received subscription — this object has no signal
## connection of its own, matching atlas_generation_proxy.gd's on_response()
## shape). Ignores responses for a stale body/center/n/min_wl_m/granularity
## (legacy OR v2, see below) — the player panned, zoomed across a rung
## boundary, or navigated away while a request was in flight, or a different
## rung's derive answers a request for a different rung, T-1150/T-1152 — the
## echoed fields ARE the staleness guard (§2, extended T-1150/T-1152),
## compared here against what THIS object most recently asked for.
##
## **Live-round finding (the second C1-shaped bug): v2 is AUTHORITATIVE over
## the legacy field whenever v2 is present — the legacy comparison is
## SKIPPED entirely, not run alongside it.** A T-1152-aware server (this
## codebase's) ALWAYS populates `granularity_v2` on the wire (Dudley's
## contract, `DistrictWindowLayer.granularity_v2`'s own doc: "Always
## populated (never `None`)"), and for `Region` responses specifically the
## LEGACY `granularity` slot carries `WINDOW_GRANULARITY_REGION_KEY`
## (`u32::MAX` = 4294967295) — a reserved KEY-SPACE TAG, not a real
## multiplier, that can never equal this object's own stored `_granularity`
## (which stays pinned at `DEFAULT_GRANULARITY`=1 for every rung this object
## requests, per that field's own doc — the legacy slot has no concept of
## Region at all). Comparing the legacy field UNCONDITIONALLY alongside v2
## therefore drops EVERY Region response as stale forever, even though the
## v2 comparison alone would have correctly accepted it — exactly the live
## bug (`_held_n` fixed; this is the same "old comparison still active
## alongside the new one" class of bug, one layer up in the staleness
## checks). Fix: branch on whether `granularity_v2` is actually PRESENT in
## the response dict (`w.has(...)`, not `w.get(..., default)` — the
## presence/absence distinction is the whole point here) — present (every
## real server, always) -> v2 is the ONLY granularity comparison; absent (a
## hypothetically old, pre-T-1152 server) -> fall back to the legacy
## comparison alone, matching this object's own pre-T-1152 behavior exactly.
## PR #192 cold-start round 3: the coordinator's live cold-server capture
## (retries=0, pending=true, forever) exposed that the OLD version of this
## function returned unconditionally whenever the WHOLE response's status
## wasn't "Ready" — treating a cold body's `status: "Pending"` (the FIRST
## request against a whole-body cache miss, before ANY layer including the
## window has even been queued — `serve_district_window`/`get_or_generate()`
## in server/src/atlas/layer_proxy.rs) identically to `NotFound`/`Error`: a
## silent no-op, never reaching the retry-scheduling code at all. Confirmed
## server-side: `status: Ready` is set ONLY on the whole-body cache-HIT
## branch, entirely independent of whether the WINDOW itself has resolved —
## so a cold body's first-ever window request gets `Pending` at the OUTER
## layer, while a body someone has already warmed (a later connection, or
## this SAME connection's own re-request once its own AnalyzeBody has
## landed) gets `Ready` with `district_window: null` inside it, correctly
## reaching the retry branch below. Same "still generating" signal, two
## different wire shapes depending on which cache warmed first — the fix is
## to treat BOTH as the identical retry-worthy state, matching
## atlas_generation_proxy.gd's own on_response() `match` shape exactly
## (Ready -> handle, Pending -> retry, NotFound/Error -> give up now, not
## after MAX_RETRIES: a real error is never going to resolve by waiting).
func on_response(response: Dictionary) -> void:
if str(response.get("body_id", "")) != _body_id:
return
var status := str(response.get("status", ""))
if status == "Pending":
_retry_if_pending()
return
if status != "Ready":
_pending = false # NotFound / Error — a real failure, not a queue wait; give up now
return
var window: Variant = response.get("district_window")
if window == null:
# §1: an as-yet-underived window rides as `district_window: None`
# inside an OUTER-Ready response — the whole-body cache already
# warmed, but this specific window hasn't derived yet. Same
# "still generating" signal the outer-Pending branch above handles,
# just the OTHER wire shape it can arrive in.
_retry_if_pending()
return
var w: Dictionary = window
var echoed_center := _vec_from_center(w.get("center", [0, 0]))
var echoed_n := int(w.get("n", 0))
var echoed_min_wl_m := int(w.get("min_wl_m", 0))
var granularity_matches: bool = _echoed_granularity_matches(w)
if (
echoed_center != _center
or echoed_n != _n
or echoed_min_wl_m != _min_wl_m
or not granularity_matches
):
return # stale — answers a window we've since panned/zoomed away from, or a different rung
_pending = false
_retries = 0
_cache.put(_body_id, _center, _n, w, _granularity, _min_wl_m, _granularity_v2)
window_ready.emit(w)
## Shared "still generating, re-poll" logic for BOTH wire shapes on_response()
## can see it in (outer status=="Pending", or inner district_window==null
## inside an outer Ready) — re-request until the derive lands or the retry
## ceiling is hit (queue-based serving, PR #185 — the response lands on a
## LATER tick, never this same round-trip).
func _retry_if_pending() -> void:
if not _pending:
return
if _retries < MAX_RETRIES:
_retries += 1
_schedule_retry()
else:
_pending = false # gave up — caller's border-fade / empty state persists
## The granularity half of on_response()'s staleness check, split out for the
## v2-authoritative-when-present precedence rule (see on_response()'s own
## doc for the full live-round rationale). Presence, not value, is the
## branch: `w.has("granularity_v2")` — a real server ALWAYS sets this key
## (even if its value happened to coincidentally equal a default), so
## checking presence rather than "is it the default value" is the only
## correct way to distinguish "an old server that never heard of this field"
## from "a new server whose value happens to match."
func _echoed_granularity_matches(w: Dictionary) -> bool:
if w.has("granularity_v2"):
return str(w.get("granularity_v2")) == _granularity_v2
var echoed_granularity := int(w.get("granularity", AtlasWindowCache.DISTRICT_GRANULARITY))
return echoed_granularity == _granularity
## Pure: the exponential-backoff delay for retry attempt number `retry_count`
## (1-indexed — the FIRST retry, right after the initial request's own
## PENDING answer, uses `retry_count=1`), staggered by `stagger_index`
## (STAGGER_STEP*stagger_index added on top — deterministic, not randomized,
## so a test can assert the exact delay sequence for tile N directly). Split
## out from _schedule_retry() as a pure function for the same reason every
## other formula in this file is: directly unit-testable without a live
## Timer/SceneTree.
static func _retry_delay_for(retry_count: int, stagger_index: int) -> float:
var base: float = INITIAL_RETRY_DELAY * pow(2.0, float(maxi(retry_count - 1, 0)))
var capped: float = minf(base, MAX_RETRY_DELAY)
return capped + STAGGER_STEP * float(stagger_index)
func _schedule_retry() -> void:
var delay: float = _retry_delay_for(_retries, _stagger_index)
var timer := get_tree().create_timer(delay)
timer.timeout.connect(
func() -> void:
if _pending:
SimBridge.request_atlas_layers(
_body_id, "Topography", _center, _n, _granularity, _min_wl_m, _granularity_v2
)
)
func is_pending() -> bool:
return _pending
## The v2 granularity ("Quarter" | "District" | "Region") this object most
## recently asked for — the viewer reads this to know which rung the HELD
## window (once it arrives) actually is, without threading a second copy of
## the state through window_ready's payload.
func get_granularity_v2() -> String:
return _granularity_v2
## Current window extent in districts, as CLAMPED — the viewer's rung-
## selection math needs this to compute the held composite's real-world
## extent regardless of which rung last resolved it.
func get_n() -> int:
return _n
func get_cache() -> Variant:
return _cache
static func _vec_from_center(center: Variant) -> Vector2i:
if center is Array and center.size() >= 2:
return Vector2i(int(center[0]), int(center[1]))
return Vector2i.ZERO
@@ -1,203 +0,0 @@
extends Node
## Orbital rest-state TILE-SET orchestration (T-1153, live round 3 — Jeroen's
## ruling, design doc §4: "the top rest state is the WHOLE body, served as
## progressive capped-density TILING"). A single wire-capped Region window
## (AtlasWindowGeometry.MAX_COVERAGE_M["Region"] = 13,107,200 m) covers only a
## fraction of a real body's circumference (Lendel: ~39,197,023 m — a single
## window is ~a third of the body, the exact live-round finding: shot 01's
## own header read "13107.2 x 13107.2 km" against a 39,198 km circumference).
##
## Owns N independent `AtlasWindowRequest` child instances — one per tile —
## reusing 100% of the EXISTING, already-tested single-window request/cache/
## debounce/retry machinery (atlas_window_request.gd) rather than
## reinventing multi-window orchestration from scratch. Each tile is just a
## Region-granularity window request at its own canonicalized center
## (AtlasWindowGeometry.compute_tile_grid()); distinct centers are already
## distinct cache/coalescing keys (T-1150/T-1152's own aliasing discipline),
## so nothing about the request/cache LAYER needed to change for tiling to
## work — only the ORCHESTRATION (issue N requests instead of one) and the
## DRAWING (a mosaic instead of one composite) are new.
##
## No `class_name` on purpose, matching every other viewer-owned helper in
## this cluster (atlas_window_request.gd/atlas_overlay_bar.gd/
## atlas_legend_panel.gd, review #8 precedent): the owner (AtlasWindowViewer)
## passes itself to `_init()`.
##
## Progressive arrival (design doc §4's own "with visible refinement as
## tiles complete"): each tile's `AtlasWindowRequest.window_ready` connects
## independently — a tile's own `_tiles[i]["window"]` updates the moment
## THAT tile's response lands, with no dependency on any other tile's
## arrival. The viewer/overlay reads `get_tiles()` every draw and renders
## whichever tiles have arrived so far — an empty/border-fade gap for the
## rest, exactly the same "hold what's there, sharpen in place" contract
## single-window progressive refinement already has (§6 "no mode flip"),
## just per-tile instead of per-composite.
signal tile_ready(index: int) # a single tile's window arrived/updated — the viewer redraws
const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd")
const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
var _owner = null # AtlasWindowViewer (untyped to avoid cyclic ref)
var _body_id: String = ""
var _tile_n: int = AtlasWindowGeometry.TILE_N
## Array[Dictionary]: {"center": Vector2i, "request": AtlasWindowRequest,
## "window": Variant (null until arrived)} — one entry per tile, in the SAME
## deterministic order compute_tile_grid() produces (stable fill order, see
## that function's own doc).
var _tiles: Array = []
func _init(owner_ref = null) -> void:
_owner = owner_ref
## Unlike an individual AtlasWindowRequest (which has no signal connection of
## its own — the OWNING viewer forwards responses to it, per that class'
## own doc), the tile set DOES connect directly to
## SimBridge.atlas_layers_received itself and fans a single response out to
## EVERY tile's own `on_response()` — each tile's OWN staleness guard
## (center/n/granularity_v2) decides whether that particular response is
## the one IT was waiting for; only the matching tile ever adopts it. This
## is the same "one shared inbound signal, N independent consumers filtering
## by their own criteria" shape the design already uses elsewhere (every
## AtlasWindowRequest instance filters on its own state from a common
## broadcast — tiling just means N instances share the broadcast instead of
## one).
func _ready() -> void:
SimBridge.atlas_layers_received.connect(_on_atlas_layers_received)
func _exit_tree() -> void:
if SimBridge.atlas_layers_received.is_connected(_on_atlas_layers_received):
SimBridge.atlas_layers_received.disconnect(_on_atlas_layers_received)
func _on_atlas_layers_received(response: Dictionary) -> void:
for tile: Dictionary in _tiles:
var request = tile["request"]
if is_instance_valid(request):
request.on_response(response)
## Enter tile mode for `body_id`/`body_radius_km` — computes the tile grid,
## tears down any PREVIOUS tile set's child request nodes (a fresh
## enter_orbital() on a DIFFERENT body must not leave stale tile requests
## from the old body wired up), and issues one request per tile immediately
## (no debounce — matching AtlasWindowRequest.request_now()'s own "first
## window" contract, §5: entry is never debounced, only pan/rung-reselect
## refetches are).
func enter(body_id: String, body_radius_km: float) -> void:
_teardown()
_body_id = body_id
var centers: Array = AtlasWindowGeometry.compute_tile_grid(body_radius_km)
for i in range(centers.size()):
var center: Vector2i = centers[i]
var request = AtlasWindowRequest.new(self)
request.name = "Tile%d" % i
# Cold-start dossier round 3 hardening: deterministic per-tile retry
# stagger (STAGGER_STEP*i) — without it, all 6 tiles go pending in the
# same frame and retry in perfect lockstep, a request pulse every
# RETRY_DELAY instead of a spread trickle. Set BEFORE request_now()
# so it's already in place for the very first retry, if one fires.
request._stagger_index = i
add_child(request)
var tile_index := i # capture by value for the lambda below
request.window_ready.connect(
func(window: Dictionary) -> void: _on_tile_window_ready(tile_index, window)
)
_tiles.append({"center": center, "request": request, "window": null})
request.request_now(body_id, center, _tile_n, AtlasWindowRequest.GRANULARITY_V2_REGION)
func _on_tile_window_ready(index: int, window: Dictionary) -> void:
if index < 0 or index >= _tiles.size():
return # a stale signal from a torn-down tile set (shouldn't happen — disconnected on teardown)
_tiles[index]["window"] = window
tile_ready.emit(index)
## Tear down every tile's request node — disconnects nothing explicitly
## (queue_free() on a Node disconnects all its own signal connections
## automatically, Godot's documented behavior) but DOES clear `_tiles` so a
## stale index from an in-flight-but-now-orphaned request's eventual
## response can never reach `_on_tile_window_ready()` with a now-meaningless
## index (guarded there too, belt-and-suspenders).
func _teardown() -> void:
for tile: Dictionary in _tiles:
var request = tile["request"]
if is_instance_valid(request):
request.queue_free()
_tiles.clear()
## The current tile set, for the viewer/overlay to draw — an Array of
## {"center": Vector2i, "window": Variant} (the "request" key is internal,
## not exposed here; callers only need center + arrived-or-null window).
func get_tiles() -> Array:
var result: Array = []
for tile: Dictionary in _tiles:
result.append({"center": tile["center"], "window": tile["window"]})
return result
## True while at least one tile's `window` hasn't arrived yet — the viewer's
## cold-start self-healing redraw (see AtlasWindowViewer._process()'s own
## doc) polls this every frame so a mosaic's paint can never silently wedge
## behind a lost/late queue_redraw() no matter which signal edge it was
## supposed to ride in on. Also the source of truth for whether the §4
## pending treatment should show.
func has_pending_tiles() -> bool:
for tile: Dictionary in _tiles:
if tile["window"] == null:
return true
return false
## True once at least ONE tile has a real window — distinct from
## has_pending_tiles()'s "at least one MISSING" (both can be true at once,
## mid-arrival). PR #192 cold-start round 2: a cold server's first
## AnalyzeBody can take >10s with ZERO tiles landed the whole time — the
## per-tile border-fade wash alone (subtle, same color as every OTHER
## no-data-yet state) read as broken darkness in a live cold capture, not
## loading. The viewer uses this to gate an unmistakable "DERIVING
## TERRAIN…" label: shown while this is false (nothing has arrived at all —
## the reassurance is needed most), dropped the moment even one tile lands
## (per-tile washes alone read fine once real content is visibly filling in
## around the gaps).
func has_any_tile_arrived() -> bool:
for tile: Dictionary in _tiles:
if tile["window"] != null:
return true
return false
## True once tiling is active for the current body — a body whose whole
## circumference fits in ONE Region window's own coverage ceiling produces
## exactly one tile (compute_tile_grid()'s own degenerate-case doc), so
## `is_multi_tile()` distinguishes "tile set with 1 entry" (still tiling
## machinery, technically) from "genuinely multiple tiles" — the viewer uses
## this to decide whether the tile-set draw path or the ORIGINAL
## single-window draw path is simpler/preferred for a small body (both are
## correct; single-window avoids the extra Node/signal overhead when there's
## only ever going to be one tile).
func is_multi_tile() -> bool:
return _tiles.size() > 1
func get_tile_count() -> int:
return _tiles.size()
## True if every tile currently has an arrived window — the viewer/legend
## chrome can use this to know when the mosaic is "complete" vs. still
## progressively filling in.
func is_fully_arrived() -> bool:
if _tiles.is_empty():
return false
for tile: Dictionary in _tiles:
if tile["window"] == null:
return false
return true
File diff suppressed because it is too large Load Diff
@@ -1,171 +0,0 @@
extends RefCounted
## T-1172 — the river-skeleton waterline-clip fix. Pure geometry, split into
## its own file (not folded into atlas_window_geometry.gd, which is already
## close to the gdlint max-file-lines cap): the river skeleton's own SOURCE
## (server/src/atlas/drainage.rs) is filtered against the RAW heightmap sea
## level, but the DRAWN ocean this client actually paints is the derived
## MorphologyZone verdict (server/src/atlas/district_profile.rs) — which
## post-T-1162 includes the coast-warp invention (the drawn coastline is
## deterministically displaced from the heightmap coast) and, at Region
## rung, aggregates to 204.8 km cells. These are two independently-computed
## waterlines that can legitimately disagree; Tyre's ruling (T-1172): no
## single server waterline is well-defined, so the fix is a CLIENT draw-time
## clip against whichever composite cell is currently ON SCREEN at a given
## river dot's position — strict drop, no snap (a dot that lands on drawn
## water is simply not drawn; Region's 205 km cells may amputate a river's
## final coastal dots, an accepted cost per the ruling).
##
## T-1170 Ruling 3g update (RESTRUCTURED, not blanket-retired): the clip is
## RETIRED for the District/Quarter COURSE-drawing rungs
## (AtlasWindowNatureOverlay._draw_course_path()/_draw_one_course()) —
## courses carry real rung-consistent termini invented server-side against
## the SAME rung's drawn coast, so the clip's job is already done there. This
## file's clip machinery is STILL LIVE and used at the Region SKELETON path
## (_draw_skeleton_chords()/_segment_touches_drawn_water()) — Region still
## draws the whole-body skeleton against a rung-dependent drawn coast, which
## is precisely the presentation-frame reconciliation this file exists for.
## The Region clip is PERMANENT-UNTIL-REGION-GOES-WINDOWED (T-1143 ruling 2's
## progressive tiling) — when Region itself becomes a windowed rung, it
## inherits windowed courses too, and this file retires entirely at that
## point, not before.
##
## const AtlasWindowWaterClip := preload("res://ui/implant/apps/atlas/atlas_window_water_clip.gd")
const AtlasWindowGeometryRef := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
## Sentinel returned by the lookups below when no arrived composite data
## covers the queried position — either the position is outside every
## held/tiled window's own extent, or the window/tile at that position
## hasn't arrived yet. The caller (AtlasWindowNatureOverlay) must FAIL OPEN
## on this sentinel (draw the dot) — Tyre's rule 5: the clip is a
## presentation refinement, never a data gate. Chosen as -1 (not a legal
## MorphologyZone discriminant, which is always >= 0) so it can never be
## mistaken for a real "not water" zone.
const MORPHOLOGY_ZONE_NO_DATA: int = -1
## The derived per-cell grid side length (CELLS) for a window dict `w` — a
## DELIBERATE duplicate of AtlasWindowOverlay.cell_grid_side_for_window(),
## not a shared call, matching this codebase's own "each file owns its own
## reading of a small pure lookup rather than force a dependency" precedent
## (atlas_overlay_colors.gd's header doc states this explicitly for the
## color-palette case; the SAME rationale applies here: atlas_window_overlay.gd
## already depends on atlas_window_geometry.gd, so a dependency back from
## there — or from this file, if it lived there — would risk a circular or
## at least confusing import graph). Mirrors
## server/src/atlas/layer_proxy.rs's `WindowGranularity::cell_grid_side`
## exactly, matching the canonical function's own doc byte-for-byte in intent.
static func cell_grid_side_for_window(w: Dictionary) -> int:
var n: int = int(w.get("n", 0))
var granularity_v2 := str(w.get("granularity_v2", "District"))
match granularity_v2:
"Quarter":
return n * 4
"Region":
return maxi(roundi(float(n) / 100.0), 1)
_:
return n
## Resolve a FRACTIONAL district position to the MorphologyZone discriminant
## of the composite cell covering it, for a SINGLE window dict `w` (the
## single-window rung path: District/Quarter, and each individual Region
## tile in tile mode share this same per-window shape). Returns
## MORPHOLOGY_ZONE_NO_DATA if `w` is null/malformed, has no morphology array,
## or `district` falls outside `w`'s own `[center - n/2, center + n/2)`
## extent (the SAME containment convention
## AtlasWindowGeometry.district_to_canvas_local() uses, so a position judged
## "inside" here is exactly the position that would draw as part of THIS
## window's composite on screen — no separate containment rule to drift out
## of sync with the actual paint).
static func morphology_zone_in_window(district: Vector2, w: Variant) -> int:
if not w is Dictionary:
return MORPHOLOGY_ZONE_NO_DATA
var window: Dictionary = w
var center_raw: Variant = window.get("center", [0, 0])
var center: Vector2i = (
Vector2i(int(center_raw[0]), int(center_raw[1])) if center_raw is Array else Vector2i.ZERO
)
var n: int = int(window.get("n", 0))
if n <= 0:
return MORPHOLOGY_ZONE_NO_DATA
var half: float = float(n) * 0.5
var local_x: float = district.x - (float(center.x) - half)
var local_y: float = district.y - (float(center.y) - half)
if local_x < 0.0 or local_x >= float(n) or local_y < 0.0 or local_y >= float(n):
return MORPHOLOGY_ZONE_NO_DATA
var morphology: Variant = window.get("morphology")
if not (morphology is PackedByteArray or morphology is Array):
return MORPHOLOGY_ZONE_NO_DATA
var grid_side: int = cell_grid_side_for_window(window)
if grid_side <= 0:
return MORPHOLOGY_ZONE_NO_DATA
# T-1172 round 2: SHARED index formula with the terrain painter
# (AtlasWindowGeometry.cell_index_for_local_offset() — see its own doc
# for why this is now factored out instead of duplicated).
var cell: Vector2i = AtlasWindowGeometryRef.cell_index_for_local_offset(
local_x, local_y, n, grid_side
)
var idx: int = cell.y * grid_side + cell.x
if idx < 0 or idx >= morphology.size():
return MORPHOLOGY_ZONE_NO_DATA
return int(morphology[idx])
## Resolve a fractional district position to a MorphologyZone discriminant
## across BOTH viewer modes — the single dispatch point
## AtlasWindowNatureOverlay's clip predicate calls, so it never needs its own
## is_tile_mode() branch. Single-window mode: one direct
## morphology_zone_in_window() call against `single_window`. Tile mode:
## linear scan of `tiles` (Array of {"center": Vector2i, "window": Variant},
## AtlasWindowTileSet.get_tiles()'s own shape) for whichever tile's ON-SCREEN
## extent contains the position — each tile's OWN echoed `window["n"]` is
## used for the actual containment test (not TILE_N assumed), matching this
## cluster's "the response is the source of truth for what it actually
## contains" precedent, since a clamped/still-arriving tile's real extent
## can differ from the nominal per-tile request size.
##
## **Live round 2 fix (coordinator's trace, T-1172):** the wrap resolution
## MUST mirror AtlasWindowOverlay._draw_tile_mosaic()'s own
## `draw_col = nearest_wrap_image(center.x, held_center.x, cols)` EXACTLY —
## wrap the TILE'S OWN CENTER toward `held_center` (the viewer's currently-
## displayed reference frame), then test the (already held-center-wrapped)
## query `district` against that RESOLVED center. The original version did
## the inverse — wrapped the QUERY toward the tile's raw CANONICAL center —
## which is not the same operation and silently tested containment against
## the WRONG wrap-image of the tile for any tile whose canonical center is
## far from `held_center` (i.e. any tile that needs wrapping to appear
## on-screen at all — confirmed live: a dot at district.x=-9569 visibly
## sitting on the painter's WEST wrap-image of the seam tile
## (canonical center 12739, drawn at draw_col=-6400) was tested by the old
## code against that tile's EAST/canonical span `[9539, 15939)` instead —
## landed inside it by coincidence (mod arithmetic), read a real but
## WRONG-LOCATION land cell, and never clipped). `district.x` is assumed
## ALREADY wrap-resolved near `held_center` by the caller (AtlasWindowNatureOverlay.
## _district()'s own contract) — this function does not re-wrap it, only the
## tile centers, exactly mirroring the painter's own asymmetry (the painter
## never wrap-resolves the query either — canvas-local coordinates are
## already in the held-center frame by construction).
static func resolve_morphology_zone(
district: Vector2, is_tile_mode: bool, single_window: Variant, tiles: Array, cols: int,
held_center_x: int = 0
) -> int:
if not is_tile_mode:
return morphology_zone_in_window(district, single_window)
for tile: Dictionary in tiles:
var window: Variant = tile.get("window")
if not window is Dictionary:
continue
var tile_center: Vector2i = tile.get("center", Vector2i.ZERO)
var draw_col: int = tile_center.x
if cols > 0:
draw_col = AtlasWindowGeometryRef.nearest_wrap_image(tile_center.x, held_center_x, cols)
var effective_window: Dictionary = window
if draw_col != tile_center.x:
effective_window = (window as Dictionary).duplicate()
effective_window["center"] = [draw_col, tile_center.y]
var zone: int = morphology_zone_in_window(district, effective_window)
if zone != MORPHOLOGY_ZONE_NO_DATA:
return zone
return MORPHOLOGY_ZONE_NO_DATA
@@ -1,62 +1,46 @@
class_name RegionalScreen
extends Control
## Regional zoom-ladder screen for AtlasApp (#844, D-191; superseded T-1153
## D-226 T-1143-rulings amendment). Thin wrapper around AtlasWindowViewer,
## entering at the CANONICAL ORBITAL FRAME (Region rung) via enter_orbital()
## instead of AtlasViewer's retired heightmap-texture show_body() path —
## enter/leave are still the nav interface, unchanged shape.
## Regional zoom-ladder screen for AtlasApp (#844, D-191; rebuilt T-1182
## D-255 stepped Atlas ladder). Thin wrapper around StepCanvasViewer,
## entering at the Global opener (rung 0, D-255(a)) via enter() — enter/leave
## are still the nav interface, unchanged shape from the retired
## AtlasWindowViewer/enter_orbital() this replaces.
##
## T-1152 client half: this is the ONE screen for the whole ladder now —
## there is no separate "district" nav hop for the windowed drill-down
## (D-013 "the zoom gesture owns spatial descent" restored for this seam
## means descent is a CONTINUOUS in-screen zoom, not a nav-stack push). Esc
## T-1182: this is the ONE screen for the whole six-rung ladder — Global
## through Chunk are all served by the SAME StepCanvasViewer, no separate
## orbital-mosaic-vs-window split (that split retired with AtlasWindowViewer
## itself, D-255(a): "Global opener is rung 0, one viewer one path"). Esc
## from anywhere in the ladder is a single nav.pop() back to whatever pushed
## "regional" (system screen) — see atlas_app.gd's _handle_key(), unchanged
## from before this ticket (it already routed Esc through nav.pop() for any
## screen that isn't "reach"/"system"-with-a-panel-open).
##
## `district_descend_requested`/`economics_link_requested` signals retire
## with AtlasViewer's click-through (the reticle/hover-to-descend affordance
## — Jeroen's ruling: retired as the SOLE entry, and no cheap click-target
## exists on the orbital Region-rung view to wire a shortcut onto yet, unlike
## a future settlement-marker click which WOULD have a natural landing
## point — see AtlasWindowViewer's own doc on why enter() still exists as a
## District-rung entry point for exactly that future wiring).
## economics_link_requested is deferred with AtlasViewer's city-click sidebar
## (see the batch report for the full list of what's deferred vs. carried).
## "regional" (system screen) — see atlas_app.gd's _handle_key(), unchanged.
signal back_requested
var _viewer: AtlasWindowViewer = null
var _viewer: StepCanvasViewer = null
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_STOP
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_viewer = AtlasWindowViewer.new()
_viewer.name = "AtlasWindowViewer"
_viewer = StepCanvasViewer.new()
_viewer.name = "StepCanvasViewer"
add_child(_viewer)
_viewer.back_pressed.connect(_on_viewer_back)
## Cold-start dossier (BUG 2, PR #192 review): ImplantApp._on_screen_changed()
## calls enter() unconditionally on EVERY screen_changed, including a repeat
## nav.push("regional", ...) that lands on the SAME screen already showing —
## reachable from more than one input path (body-click, panel+Enter) and,
## on a slow cold server, plausible for a player to trigger twice before the
## first descent settles. Without this guard, a repeat push tore down and
## rebuilt the whole tile set (orphaning in-flight requests, live round 6's
## exact storm shape for a different trigger) and refreshed the legend from
## scratch each time — confirmed source of the ~10x legend stack. Guarded
## on body_id alone (not a deep payload compare): the same body, re-entered,
## should always resume the SAME orbital session already in flight, never
## restart it — an actual body CHANGE (different id) still re-enters fresh.
## Cold-start dossier (BUG 2, PR #192 review — carried forward): ImplantApp.
## _on_screen_changed() calls enter() unconditionally on EVERY screen_changed,
## including a repeat nav.push("regional", ...) that lands on the SAME screen
## already showing — reachable from more than one input path (body-click,
## panel+Enter). Guarded on body_id alone (not a deep payload compare): the
## same body, re-entered, should always resume the SAME session already in
## flight, never restart it — an actual body CHANGE (different id) still
## re-enters fresh.
func enter(payload: Dictionary) -> void:
var body: Dictionary = payload.get("body", {})
var system: Dictionary = payload.get("system", {})
if str(body.get("body_id", "")) == _viewer.get_body_id():
return
_viewer.enter_orbital(body, system)
_viewer.enter(body, system)
func leave() -> void:
@@ -0,0 +1,178 @@
class_name StepCanvasAnnotationLayer
extends Node2D
## The unscaled screen-space annotation sibling (T-1182, D-255(a)/(e)) —
## river course polylines, settlement markers, drawn at LITERAL px sizes,
## positions world->screen transformed per-frame via a plain linear map
## (StepCanvasTransport.world_m_to_canvas_local()). This is the layer that
## makes `_zs()`/`_zs_stroke()`/`_zs_ring_radius()` structurally
## unnecessary: because this Node2D is NEVER scaled (no `.scale` write
## anywhere in this file, unlike the retired `_canvas.scale` model), a
## constant like COURSE_WIDTH_PX below already IS the on-screen width with
## no compensating division — "by construction, not by discipline" per
## Stig's round-1 design doc.
##
## Data source: the SAME decoded StepCanvasResponse `canvas` Dictionary the
## terrain layer reads (`courses`/`cliffs`/`settlement_id` — sparse
## MessagePack-native lists + one dense array, all arriving together on the
## one flat tagged response, D-255(c): "one flat tagged response carries
## every field together"). No separate whole-body Layer-1 fetch (unlike the
## retired atlas_window_nature_overlay.gd's two-source model) — every rung's
## annotations come from that SAME rung's own canvas.
##
## Draw order (bottom to top): course polylines, then settlement markers —
## matches the retired cluster's "basins-under-rivers-under-attractors"
## precedent (features layer over terrain, points over lines).
const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd")
## Reused verbatim from the retired atlas_window_nature_overlay.gd (itself
## reused from the retired atlas_marker_overlay.gd, "Araminta's ruling:
## reuse the retired palette exactly") — same values, same source of truth.
const COLOR_RIVER: Color = Color(0.353, 0.647, 0.776, 1.0)
const COLOR_MOUTH: Color = Color(0.353, 0.647, 0.776, 1.0)
const COLOR_SETTLEMENT: Color = Color(0.94, 0.82, 0.38, 1.0)
## River class ids — mirrors server/src/atlas/body_world_state.rs
## RiverNetwork.river_class's own doc (0=stream, 1=tributary, 2=trunk),
## same vocabulary the retired atlas_window_geometry_nature.gd used.
const RIVER_CLASS_STREAM: int = 0
const RIVER_CLASS_TRIBUTARY: int = 1
const RIVER_CLASS_TRUNK: int = 2
## Course polyline width/opacity per class — LITERAL screen-space px/alpha,
## no zoom compensation needed (this layer is never scaled). Same functional
## defaults as the retired COURSE_CLASS_WIDTH_PX/COURSE_CLASS_OPACITY tables
## (Araminta's ruling, trunk widest/stream thinnest).
const COURSE_CLASS_WIDTH_PX: Dictionary = {
RIVER_CLASS_STREAM: 0.9, RIVER_CLASS_TRIBUTARY: 1.4, RIVER_CLASS_TRUNK: 2.2
}
const COURSE_CLASS_OPACITY: Dictionary = {
RIVER_CLASS_STREAM: 0.8, RIVER_CLASS_TRIBUTARY: 0.9, RIVER_CLASS_TRUNK: 1.0
}
const MOUTH_RING_RADIUS_PX: float = 5.0
const MOUTH_HALO_RADIUS_PX: float = 8.0
const MOUTH_HALO_ALPHA: float = 0.30
const SETTLEMENT_MARKER_RADIUS_PX: float = 3.5
const COURSE_TERMINUS_MOUTH: String = "Mouth"
var _canvas: Variant = null # decoded StepCanvasResponse.canvas — null until first arrival
var _world_center: Vector2 = Vector2.ZERO
var _rung: String = StepCanvasTransport.RUNG_DISTRICT
var _extent_cells: Vector2i = Vector2i.ZERO
## Adopt a new canvas + its request frame (world center, rung, extent) — the
## world->screen projection for every drawn feature depends on all three,
## so they're set together, matching the terrain layer's own texture-plus-
## frame handoff.
func set_frame(
canvas: Variant, world_center: Vector2, rung: String, extent_cells: Vector2i
) -> void:
_canvas = canvas
_world_center = world_center
_rung = rung
_extent_cells = extent_cells
queue_redraw()
func clear_frame() -> void:
_canvas = null
queue_redraw()
func _draw() -> void:
if not _canvas is Dictionary:
return
var d: Dictionary = _canvas
_draw_courses(d.get("courses", []))
_draw_settlements(d)
func _draw_courses(courses: Array) -> void:
for course_raw: Variant in courses:
if not course_raw is Dictionary:
continue
_draw_one_course(course_raw)
func _draw_one_course(course: Dictionary) -> void:
var cls: int = int(course.get("class", RIVER_CLASS_TRUNK))
var points_raw: Variant = course.get("points")
if not points_raw is Array or (points_raw as Array).size() < 2:
return
var screen_pts := PackedVector2Array()
for pt: Variant in points_raw:
if not (pt is Array and pt.size() >= 2):
continue
var world_m := Vector2(float(pt[0]), float(pt[1]))
screen_pts.append(_world_to_local(world_m))
if screen_pts.size() < 2:
return
var width: float = float(COURSE_CLASS_WIDTH_PX.get(cls, COURSE_CLASS_WIDTH_PX[RIVER_CLASS_STREAM]))
var opacity: float = float(COURSE_CLASS_OPACITY.get(cls, COURSE_CLASS_OPACITY[RIVER_CLASS_STREAM]))
var color := Color(COLOR_RIVER.r, COLOR_RIVER.g, COLOR_RIVER.b, COLOR_RIVER.a * opacity)
draw_polyline(screen_pts, color, width, true)
var terminus: String = str(course.get("terminus", ""))
if terminus == COURSE_TERMINUS_MOUTH:
_draw_mouth_ring(screen_pts[screen_pts.size() - 1])
func _draw_mouth_ring(local_pt: Vector2) -> void:
var halo_color := Color(COLOR_MOUTH.r, COLOR_MOUTH.g, COLOR_MOUTH.b, MOUTH_HALO_ALPHA)
draw_arc(local_pt, MOUTH_HALO_RADIUS_PX, 0.0, TAU, 24, halo_color, 3.0)
draw_arc(local_pt, MOUTH_RING_RADIUS_PX, 0.0, TAU, 18, COLOR_MOUTH, 1.5)
## Settlement markers — one per DISTINCT non-zero `settlement_id` cell,
## drawn at the cell's own world-metre center (not every covered cell — a
## marker per settlement anchor reads as a landmark, a marker per cell would
## flood the layer with a proximity-radius-sized cluster of dots). Cheap:
## settlement coverage is sparse by construction (SETTLEMENT_COVERAGE_RADIUS_M
## is a small disc relative to a fixed-rung canvas), so a single linear scan
## collecting first-seen positions per id is a one-off cost per canvas
## arrival, not a per-frame cost (this function only runs inside _draw(),
## itself only invoked on queue_redraw(), never a _process() poll).
func _draw_settlements(canvas: Dictionary) -> void:
var settlement_id: Variant = canvas.get("settlement_id")
if not (settlement_id is Array or settlement_id is PackedByteArray):
return
var width: int = int(canvas.get("width", 0))
if width <= 0:
return
var seen: Dictionary = {} # settlement id -> true, first-seen-cell-only
var ids: Array = settlement_id
for i in range(ids.size()):
var sid: int = int(ids[i])
if sid == 0 or seen.has(sid):
continue
seen[sid] = true
var col: int = i % width
var row: int = i / width
var world_m: Vector2 = _cell_center_world_m(col, row)
var local_pt: Vector2 = _world_to_local(world_m)
draw_circle(local_pt, SETTLEMENT_MARKER_RADIUS_PX, COLOR_SETTLEMENT)
## The world-metre center of gridunit (col, row) in the currently-held
## canvas — the inverse of step_canvas.rs's own per-cell placement
## (`center_world_m + (col - half_w) * step_m`), mirrored client-side so a
## settlement marker lands on the exact cell its id was read from.
func _cell_center_world_m(col: int, row: int) -> Vector2:
var spacing: float = StepCanvasTransport.spacing_for_rung(_rung)
var half_w: float = float(_extent_cells.x) * 0.5
var half_h: float = float(_extent_cells.y) * 0.5
return Vector2(
_world_center.x + (float(col) - half_w) * spacing,
_world_center.y + (float(row) - half_h) * spacing
)
func _world_to_local(world_m: Vector2) -> Vector2:
return StepCanvasTransport.world_m_to_canvas_local(world_m, _world_center, _rung, _extent_cells)
@@ -0,0 +1,107 @@
extends RefCounted
## Client-side in-memory LRU cache for decoded StepCanvasResponse payloads
## (T-1182, D-255(d): "cheapest-first: client in-memory LRU -> client
## disk-backed FileAccess store... -> server"). This IS the surviving LRU
## SHAPE the T-1182 ticket names — adapted from atlas_window_cache.gd's
## erase+reinsert-is-MRU idiom (Godot Dictionary preserves insertion order,
## so "move to the end on touch, evict from the front on overflow" is the
## whole implementation, no separate linked-list/counter bookkeeping), NOT
## deleted, per the ticket's own "SURVIVING SURFACES" instruction.
##
## Keyed on (body_id, rung, center, extent, min_wl_m) — the exact tuple
## server/src/atlas/step_canvas.rs's own StepCanvasCache keys on
## (StepCanvasKey = (String, StepCanvasRung, (i64,i64), (u32,u32), u32)) —
## so a client-side cache hit and a server-side cache hit are asking the
## identical question. D-227 determinism means a previously-fetched canvas
## is valid FOREVER for that exact key (geometry tier) — this is an
## LRU-evict-only cache: no freshness check, no TTL, no invalidation path.
## The disk-backed tier (T-1183) and its two-axis storage-eviction sweep are
## a SEPARATE, later concern layered underneath this one, per D-255(d)'s own
## three-tier split — this file is tier 1 only.
##
## Global (rung 0) entries key on `center=(0,0)`/`extent=(0,0)` unconditionally
## (the wire's own convention — center/extent are meaningless but still sent
## per step_canvas_protocol.gd's doc) — so every Global request for the same
## body_id collides on ONE cache slot, matching the server's own always-keep
## single-entry-per-body GlobalTierCache. This cache does NOT special-case
## a retention floor for Global the way the server's GlobalTierCache does
## (D-255(d)'s "never evicted by either eviction axis" is a SERVER-side
## always-keep guarantee) — client-side, Global is still just the
## most-recently-touched entry like anything else, protected from ordinary
## LRU pressure only by being re-touched every time the player returns to
## the top of the ladder (the common case, per this cluster's existing
## "Esc-then-re-enter and pan-back... makes this the common path"
## precedent). A dedicated client-side retention floor is exactly the
## T-1183 disk-tier ticket's Tier-1 scope, not duplicated here.
const DEFAULT_MAX_ENTRIES: int = 24
var _max_entries: int = DEFAULT_MAX_ENTRIES
var _entries: Dictionary = {} # key String -> decoded StepCanvasResponse Dictionary
func _init(max_entries: int = DEFAULT_MAX_ENTRIES) -> void:
_max_entries = maxi(1, max_entries)
## Build the cache key. `rung == "Global"` collapses center/extent to a
## fixed sentinel (0,0) regardless of what's passed — matching the wire's
## own "Global ignores center/extent" contract, so a caller that
## accidentally passes a stale center/extent for a Global request still
## lands on the correct single per-body slot.
static func make_key(
body_id: String, rung: String, center: Vector2i, extent: Vector2i, min_wl_m: int = 0
) -> String:
var key_center: Vector2i = Vector2i.ZERO if rung == "Global" else center
var key_extent: Vector2i = Vector2i.ZERO if rung == "Global" else extent
return "%s:%s:%d,%d:%d,%d:%d" % [
body_id, rung, key_center.x, key_center.y, key_extent.x, key_extent.y, min_wl_m
]
func has(
body_id: String, rung: String, center: Vector2i, extent: Vector2i, min_wl_m: int = 0
) -> bool:
return _entries.has(make_key(body_id, rung, center, extent, min_wl_m))
## Fetch a cached canvas, touching it (move-to-most-recently-used). Returns
## null on a miss.
func get_canvas(
body_id: String, rung: String, center: Vector2i, extent: Vector2i, min_wl_m: int = 0
) -> Variant:
var key := make_key(body_id, rung, center, extent, min_wl_m)
if not _entries.has(key):
return null
var value: Variant = _entries[key]
_entries.erase(key)
_entries[key] = value
return value
## Store a decoded canvas, evicting the least-recently-used entry if over
## capacity. Overwriting an existing key also counts as a touch.
func put(
body_id: String,
rung: String,
center: Vector2i,
extent: Vector2i,
canvas: Dictionary,
min_wl_m: int = 0
) -> void:
var key := make_key(body_id, rung, center, extent, min_wl_m)
if _entries.has(key):
_entries.erase(key)
_entries[key] = canvas
while _entries.size() > _max_entries:
var oldest_key: String = _entries.keys()[0]
_entries.erase(oldest_key)
func size() -> int:
return _entries.size()
func clear() -> void:
_entries.clear()
@@ -0,0 +1,115 @@
extends RefCounted
## Per-cell colorize mapping for the step-canvas terrain layer (T-1182, c1
## ruling: CPU `Image.set_pixel` coloring, confirmed round 2 — "ship CPU
## coloring... use Image.set_pixel, not a hand-rolled buffer write"). Reuses
## AtlasOverlayColors' EXISTING ramp functions verbatim — the same base/
## toggle/modifier compositing model atlas_window_overlay.gd's
## `_cell_color()`/`_apply_glaciation()` already ship (morphology hue x
## elev_q lightness base layer; temp/moisture/vegetation mutually-exclusive
## toggle overlays that REPLACE the base read; glaciation as an always-on
## post-modifier). No new palette, no new ramp — the c1 ruling's whole
## premise is that today's coloring code already proves the CPU path works;
## this file is that same pipeline pointed at RawStepCanvas-shaped decoded
## fields instead of DistrictWindowLayer ones.
##
## L8 single-channel reads (Stig round-1 measurement appendix ⑤: "4-9x
## cheaper than RGBA8 at every size... a free win if any wire field... can
## ship single-channel before the client colorizes it") — morphology/elev_q/
## moisture_q/vegetation/glaciation/flooded_q all decode as Grayscale PNGs
## server-side (step_canvas.rs's png_encode_u8_plane: "enc.set_color(png::
## ColorType::Grayscale)"), so Image.load_png_from_buffer() on the client
## already produces an L8 (FORMAT_L8) Image for each plane — this file reads
## them via get_pixel().r8 (the grayscale intensity IS the raw u8
## classification/quantized value, no separate channel unpacking needed).
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
const TOGGLE_TEMP: String = "gen_dw_temp"
const TOGGLE_MOISTURE: String = "gen_dw_moisture"
const TOGGLE_VEGETATION: String = "gen_dw_veg"
const COLOR_MOISTURE_DRY: Color = Color(0.78, 0.62, 0.35, 1.0)
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
## 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).
class CellPlanes:
var morphology: Image
var elev_q: Image
var moisture_q: Image
var vegetation: Image
var glaciation: Image
var temp_dc: Array
var width: int
var height: int
## The composited color for cell (col, row), given the active toggle (empty
## string = base layer only). Mirrors atlas_window_overlay.gd's
## `_cell_color()`/`_apply_glaciation()` composition order exactly: base or
## ONE toggle (never blended), then the glaciation modifier over whichever
## is showing.
static func cell_color(planes: CellPlanes, col: int, row: int, active_toggle: String) -> Color:
var base: Color
match active_toggle:
TOGGLE_TEMP:
base = _temp_color(planes, col, row)
TOGGLE_MOISTURE:
base = _moisture_color(planes, col, row)
TOGGLE_VEGETATION:
base = _vegetation_color(planes, col, row)
_:
base = _base_color(planes, col, row)
return _apply_glaciation(planes, col, row, base)
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)
return AtlasOverlayColors.district_window_elevation_lightness(base, eq)
static func _temp_color(planes: CellPlanes, col: int, row: int) -> Color:
var idx: int = row * planes.width + col
if idx < 0 or idx >= planes.temp_dc.size():
return Color.TRANSPARENT
var t: int = int(planes.temp_dc[idx])
if t == REGION_TEMP_NONE_DC:
return Color.TRANSPARENT
return AtlasOverlayColors.region_temp_color(t)
static func _moisture_color(planes: CellPlanes, col: int, row: int) -> Color:
var m: int = clampi(_l8_value(planes.moisture_q, col, row), 0, 100)
return COLOR_MOISTURE_DRY.lerp(COLOR_MOISTURE_WET, float(m) / 100.0)
static func _vegetation_color(planes: CellPlanes, col: int, row: int) -> Color:
return AtlasOverlayColors.vegetation_color(_l8_value(planes.vegetation, col, row))
static func _apply_glaciation(planes: CellPlanes, col: int, row: int, base: Color) -> Color:
if planes.glaciation == null:
return base
return AtlasOverlayColors.glaciation_tint(base, _l8_value(planes.glaciation, col, row))
## Read one L8 plane's raw byte value at (col, row) — Image.get_pixel()
## returns a Color whose .r channel IS the grayscale intensity in [0,1] for
## an L8/FORMAT_L8 image; multiplying back by 255 and rounding recovers the
## original u8 (Godot's own get_pixel()/set_pixel() round-trip contract for
## 8-bit formats). Out-of-bounds/null plane returns 0 (the same "skip/
## fall back to zone 0" softness the rest of this cluster's dense-array
## readers already use for a malformed/short field).
static func _l8_value(plane: Image, col: int, row: int) -> int:
if plane == null or col < 0 or row < 0 or col >= plane.get_width() or row >= plane.get_height():
return 0
return int(round(plane.get_pixel(col, row).r * 255.0))
@@ -1,34 +1,29 @@
extends ImplantPanel
## Legend for AtlasWindowViewer's regional-window overlays (T-1138, D-226
## T-1124 amendment §5). Mirrors atlas_legend_panel.gd's data-driven shape
## one spec entry per overlay id, refresh() shows only the active ones — but
## scoped to the district-window screen's OWN toggle set (gen_dw_temp/
## gen_dw_moisture/gen_dw_veg) plus the two always-on layers that need a key
## even though they have no toggle id of their own: the morphology base
## (folded to ~5 family rows, per §5's "not everything earns permanent screen
## space" instinct) and the glaciation ice-tint modifier.
## Legend for StepCanvasViewer's terrain layer (T-1182) — adapted from the
## retired atlas_window_legend.gd's data-driven shape (one spec entry per
## overlay id, refresh() shows only the active ones) to the D-255(a) rung
## vocabulary. Chrome only — the compositing model (morphology base folded
## to family rows + glaciation always-on modifier + at-most-one active
## toggle section) is unchanged from the retired panel, per the ticket's own
## "compositing/legend/overlay-bar chrome (call-site updates only)"
## instruction.
##
## No `class_name` on purpose, matching atlas_legend_panel.gd (review #8
## precedent): the owner (AtlasWindowViewer) passes itself to _init().
## No `class_name` on purpose, matching every other viewer-owned helper in
## this cluster.
const PANEL_MARGIN: float = 16.0
const LEGEND_PANEL_WIDTH: float = 260.0
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd")
## The morphology base layer folds its 17 zones into ~5 family rows (§5:
## "mirroring T-1112's 'not everything earns permanent screen space'
## discipline") — the full 17-zone mapping stays in the city-click sidebar's
## reach, not duplicated here. Representative hue per family, picked from
## MORPHOLOGY_RGB_OPAQUE's own entries rather than a fresh set of colors.
const MORPHOLOGY_FAMILY_ROWS: Array = [
{"label": "water", "zones": [0, 1]}, # OpenOcean, Lake
{"label": "coastal / transition", "zones": [2, 3, 4, 5, 6, 7]}, # TidalFlat..Estuarine
{"label": "plains / river", "zones": [8, 9, 10, 11, 12]}, # AlluvialPlain..ValleyFloor
{"label": "upland", "zones": [13, 14]}, # MountainPass, Alpine
{"label": "volcanic / wetland", "zones": [15, 16]}, # Volcanic, Wetland
{"label": "water", "zones": [0, 1]},
{"label": "coastal / transition", "zones": [2, 3, 4, 5, 6, 7]},
{"label": "plains / river", "zones": [8, 9, 10, 11, 12]},
{"label": "upland", "zones": [13, 14]},
{"label": "volcanic / wetland", "zones": [15, 16]},
]
const GLACIATION_ROWS: Array = [
@@ -39,7 +34,7 @@ const GLACIATION_ROWS: Array = [
{"grade": 4, "label": "ice cap"},
]
var _viewer = null # AtlasWindowViewer (untyped to avoid cyclic ref)
var _viewer = null # StepCanvasViewer (untyped to avoid cyclic ref)
func _init(viewer_ref = null) -> void:
@@ -53,31 +48,15 @@ func reposition() -> void:
position = Vector2(PANEL_MARGIN, 60.0)
## Always shows the base-layer key (morphology + elevation reading, always
## on) plus glaciation (always-on modifier), then whichever toggle overlay is
## currently active, if any.
##
## The subtitle's km/cell reading is NOT a fixed "district window" label —
## it was until this fix hardcoded District's own 2.048 km/cell, which was a
## 100x lie whenever the viewer actually holds Region (204.8 km/cell) or
## Quarter (0.512 km/cell). Read through AtlasWindowGeometry.
## spacing_for_rung() — the SAME pure lookup _refresh_screen_header() uses
## for the main screen header's subtitle (screen_header_content()) — keyed
## off the viewer's current get_held_granularity_v2(), so the legend and
## header can never disagree. AtlasWindowViewer calls refresh() at every
## point _held_granularity_v2 changes (_enter_tile_mode(), _enter_at_rung(),
## _on_window_ready()'s rung-swap adoption) — see those call sites.
func refresh() -> void:
if _viewer == null:
return
clear()
visible = true
var spacing_km: float = AtlasWindowGeometry.spacing_for_rung(_viewer.get_held_granularity_v2()) / 1000.0
var subtitle: String = "%s window · %.3f km/cell" % [
_viewer.get_held_granularity_v2().to_lower(), spacing_km
]
add_component(ImplantHeader.new("REGIONAL LEGEND", subtitle))
var spacing_km: float = StepCanvasTransport.spacing_for_rung(_viewer.get_held_rung()) / 1000.0
var subtitle: String = "%s · %.3f km/gridunit" % [_viewer.get_held_rung().to_lower(), spacing_km]
add_component(ImplantHeader.new("ATLAS LEGEND", subtitle))
add_component(ImplantSeparator.new())
_add_morphology_section()
@@ -119,7 +98,7 @@ func _add_glaciation_section() -> void:
func _add_toggle_section(overlay_id: String) -> void:
match overlay_id:
"gen_dw_temp":
add_component(ImplantTextBlock.new("TEMPERATURE — cold->hot ramp (region colorizer, reused)"))
add_component(ImplantTextBlock.new("TEMPERATURE — cold->hot ramp"))
add_component(
ImplantDataRow.new("▦ cold", AtlasOverlayColors.COLOR_REGION_TEMP_COLD)
)
@@ -0,0 +1,194 @@
extends Node
## Step-canvas request orchestration for StepCanvasViewer (T-1182, D-255(c)/
## (d)). Owns the in-memory LRU cache, fires StepCanvasRequest frames, and
## retries on a Pending response — the same D-225 poll/cache/enqueue serving
## model atlas_window_request.gd already established for the legacy
## district_window carrier, now pointed at the new tagged envelope.
##
## No `class_name` on purpose, matching every other viewer-owned helper in
## this cluster (atlas_overlay_bar.gd/atlas_window_request.gd, established
## precedent): the owner (StepCanvasViewer) passes itself to `_init()`.
##
## **The extent ECHO rule (T-1181 wire addendum, mandatory per this ticket's
## own read-first list): read the echoed extent, NEVER assume the requested
## one.** `on_response()` below stores `_held_extent` from the RESPONSE's own
## `extent` field, not from whatever was sent — a server-side clamp
## (STEP_CANVAS_MAX_EXTENT_AXIS/CELLS) can shrink the actual canvas below
## what was asked for, and every downstream consumer (the terrain layer's
## footprint math, the annotation layer's cell-center math) must agree with
## what actually arrived, not what was requested. Global responses echo
## `(0, 0)` for extent (the wire's own convention — see
## step_canvas_protocol.gd's doc) — callers reading `get_held_extent()` for
## a Global-held canvas must special-case it themselves (the viewer does,
## via StepCanvasTransport.RUNG_GLOBAL checks), matching this ticket's own
## "Global rung ignores wire extent" instruction.
signal canvas_ready(response: Dictionary) # emitted on a cache hit OR a fresh Ready response
const StepCanvasCache := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_cache.gd")
const INITIAL_RETRY_DELAY: float = 0.5
const MAX_RETRY_DELAY: float = 4.0
const MAX_RETRIES: int = 30
var _owner = null # StepCanvasViewer (untyped to avoid cyclic ref)
var _cache: Variant = null # StepCanvasCache
var _body_id: String = ""
var _rung: String = ""
var _center: Vector2i = Vector2i.ZERO
var _extent: Vector2i = Vector2i.ZERO
var _min_wl_m: int = 0
## The LAST ADOPTED (Ready, matching) response's own echoed extent — see the
## class doc's "extent ECHO rule". Distinct from `_extent` (the most
## recently REQUESTED extent, which may not match what a still-in-flight
## request will echo back).
var _held_extent: Vector2i = Vector2i.ZERO
var _pending: bool = false
var _retries: int = 0
func _init(owner_ref = null) -> void:
_owner = owner_ref
_cache = StepCanvasCache.new()
## Reset in-flight bookkeeping for a fresh body/rung entry — does NOT clear
## the cache (D-227: a cached canvas is valid forever for that exact key).
func reset() -> void:
_pending = false
_retries = 0
## Fire (or serve from cache) a step-canvas request. Cache hit -> immediate
## synchronous canvas_ready emit, no network traffic. Cache miss -> send the
## request now; the response (or a Pending retry chain) arrives later via
## on_response().
func request_now(
body_id: String, rung: String, center: Vector2i, extent: Vector2i, min_wl_m: int = 0
) -> void:
_body_id = body_id
_rung = rung
_center = center
_extent = extent
_min_wl_m = min_wl_m
var cached: Variant = _cache.get_canvas(body_id, rung, center, extent, min_wl_m)
if cached != null:
_pending = false
_retries = 0
_held_extent = _echoed_extent(cached, rung, extent)
canvas_ready.emit(cached)
return
_pending = true
_retries = 0
SimBridge.request_step_canvas(body_id, rung, center, extent, min_wl_m)
## Handle a StepCanvasResponse (routed by the owning viewer from its own
## SimBridge.step_canvas_received subscription). Ignores a response for a
## stale body/rung/center/extent/min_wl_m (the player scrolled to a
## different step, or panned, while this was in flight) — the echoed fields
## are the staleness guard, same discipline atlas_window_request.gd's own
## on_response() uses for the legacy carrier.
func on_response(response: Dictionary) -> void:
if not _matches_this_request(response):
return
var status := str(response.get("status", ""))
if status == "Pending":
_retry_if_pending()
return
if status != "Ready":
_pending = false # NotFound / Error — a real failure, give up now
return
if not _echo_matches_held_frame(response):
return
var canvas: Variant = response.get("canvas")
if canvas == null:
# A Ready-status response with no canvas payload — treat as still
# generating (matches atlas_window_request.gd's own "district_window:
# None inside an outer Ready" precedent for the same underlying
# concept, one carrier over).
_retry_if_pending()
return
_pending = false
_retries = 0
_held_extent = _echoed_extent(canvas, _rung, response.get("extent", Vector2i.ZERO))
_cache.put(_body_id, _rung, _center, _extent, canvas, _min_wl_m)
canvas_ready.emit(canvas)
## Coarse staleness gate: body_id/rung identity alone (before touching the
## echoed frame fields, which only mean something once identity matches).
func _matches_this_request(response: Dictionary) -> bool:
if str(response.get("body_id", "")) != _body_id:
return false
return str(response.get("rung", "")) == _rung
## Fine staleness gate: the echoed center/min_wl_m must match what THIS
## object most recently asked for. Global ignores center/extent server-side
## (step_canvas_protocol.gd's own doc) — the guard skips both fields for
## that rung, matching the wire's own "meaningless but still echoed"
## contract.
func _echo_matches_held_frame(response: Dictionary) -> bool:
var echoed_min_wl_m: int = int(response.get("min_wl_m", 0))
if echoed_min_wl_m != _min_wl_m:
return false
if _rung == "Global":
return true
var echoed_center: Vector2i = response.get("center", Vector2i.ZERO)
return echoed_center == _center
## Extent to hold for downstream consumers — echoed extent for a fixed rung,
## the CANVAS's own width/height for Global (whose wire extent echo is a
## fixed (0,0) sentinel, per the class doc).
static func _echoed_extent(canvas: Variant, rung: String, echoed: Vector2i) -> Vector2i:
if rung == "Global" and canvas is Dictionary:
var d: Dictionary = canvas
return Vector2i(int(d.get("width", 0)), int(d.get("height", 0)))
return echoed
func _retry_if_pending() -> void:
if not _pending:
return
if _retries < MAX_RETRIES:
_retries += 1
_schedule_retry()
else:
_pending = false
static func _retry_delay_for(retry_count: int) -> float:
var base: float = INITIAL_RETRY_DELAY * pow(2.0, float(maxi(retry_count - 1, 0)))
return minf(base, MAX_RETRY_DELAY)
func _schedule_retry() -> void:
var delay: float = _retry_delay_for(_retries)
var timer := get_tree().create_timer(delay)
timer.timeout.connect(
func() -> void:
if _pending:
SimBridge.request_step_canvas(_body_id, _rung, _center, _extent, _min_wl_m)
)
func is_pending() -> bool:
return _pending
func get_held_extent() -> Vector2i:
return _held_extent
func get_cache() -> Variant:
return _cache
@@ -0,0 +1,146 @@
class_name StepCanvasTerrainLayer
extends Node2D
## The RTT terrain layer (T-1182, D-255(a)/(b)/(e)) — one ImageTexture per
## held step canvas, built server-side-derived / client-colorized, drawn
## texel-exact at the rung's own display ratio. This is the PROMOTION Stig's
## round-1 doc names: "_rebuild_texture_if_needed/_build_tile_texture in
## atlas_window_overlay.gd generalized from 'per-tile mosaic composite' to
## 'the one and only terrain path'" — same Image/ImageTexture/set_pixel
## pipeline, now universal across all six D-255(a) rungs instead of being
## split across a single-window path and a tile-mosaic path.
##
## **texture.update() reuse on step-cross** (c1/⑤ ruling: "prefer
## texture.update() reuse over fresh create_from_image() per step... not for
## raw speed... but because it avoids per-step Texture object churn on the
## RenderingServer side"): rebuild_from_canvas() reuses `_texture` via
## `.update()` whenever the new canvas is the SAME pixel size as the held
## one (the common case — every fixed rung requests the same viewport-fit
## extent repeatedly), and only calls `ImageTexture.create_from_image()` when
## the size actually changes (a rung crossing to a differently-sized canvas,
## or the very first canvas).
##
## **NEAREST/LINEAR per rung** (_filter_for_rung(), T-1161's surviving
## ruling, ticket SURVIVING SURFACES: "_filter_for_granularity_v2... is
## wired"): Global/Region sample NEAREST (coarse orbital-scale data —
## bilinear blending between real derived samples reads as smoothing-over-
## absence); District through Chunk sample LINEAR (cell density earns the
## blend). Renamed for the D-255(a) rung vocabulary but the POLICY is
## unchanged from the retired atlas_window_overlay.gd's own ruling.
##
## **Determinism boundary (D-255(e)):** this node draws EXACTLY the cells
## the server supplied, at the display ratio's texture-to-viewport resize
## (the one sanctioned display-time scale) — it never invents a sample
## between two server cells, never smooths across a canvas edge. The GPU's
## own LINEAR filter, where selected, blends between ADJACENT REAL SAMPLES
## already present in the texture — this is presentation resampling of a
## closed input set, not invention of a new one, matching D-255(e)'s own
## "texture-to-viewport resize" exemption.
const StepCanvasColorize := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd")
const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd")
var _texture: ImageTexture = null
var _texture_size: Vector2i = Vector2i.ZERO
var _canvas_ref: Variant = null # reference-identity rebuild-only-on-change guard
var _active_toggle: String = ""
var _footprint_px: Vector2 = Vector2.ZERO
var _held_rung: String = StepCanvasTransport.RUNG_DISTRICT
## Rebuild (or reuse) the held texture from a decoded StepCanvasResponse's
## `canvas` Dictionary (step_canvas_protocol.gd's shape: width/height +
## PackedByteArray PNG planes + raw temp_dc/settlement_id arrays). No-op if
## `canvas` is the SAME object (reference identity, is_same()) and the
## active toggle hasn't changed — matches atlas_window_overlay.gd's own
## "REBUILT only when its inputs change" discipline, now the terrain layer's
## own hold contract for the "hold-fetch-swap, not blend-fetch-swap"
## step-cross sequence (round-1 §2): the OLD texture keeps drawing (this
## function simply isn't called) while a new canvas is in flight.
func rebuild_from_canvas(canvas: Dictionary, rung: String, active_toggle: String) -> void:
if is_same(_canvas_ref, canvas) and _active_toggle == active_toggle and _texture != null:
return
var width: int = int(canvas.get("width", 0))
var height: int = int(canvas.get("height", 0))
if width <= 0 or height <= 0:
return
var planes := _decode_planes(canvas, width, height)
var img := Image.create(width, height, false, Image.FORMAT_RGBA8)
for row in range(height):
for col in range(width):
img.set_pixel(col, row, StepCanvasColorize.cell_color(planes, col, row, active_toggle))
var new_size := Vector2i(width, height)
if _texture != null and _texture_size == new_size:
_texture.update(img)
else:
_texture = ImageTexture.create_from_image(img)
_texture_size = new_size
_canvas_ref = canvas
_active_toggle = active_toggle
_held_rung = rung
_footprint_px = StepCanvasTransport.canvas_footprint_px(rung, new_size)
texture_filter = _filter_for_rung(rung)
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.
func _decode_planes(canvas: Dictionary, width: int, height: int) -> StepCanvasColorize.CellPlanes:
var planes := StepCanvasColorize.CellPlanes.new()
planes.width = width
planes.height = height
planes.morphology = _decode_l8_plane(canvas.get("morphology"))
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.glaciation = _decode_l8_plane(canvas.get("glaciation"))
planes.temp_dc = canvas.get("temp_dc", [])
return planes
## PackedByteArray PNG bytes -> Image, or null on a decode failure/empty
## input (a malformed/missing plane draws as the composite's own fallback
## color for that cell — StepCanvasColorize's _l8_value() treats a null
## plane as 0, never crashes).
static func _decode_l8_plane(field: Variant) -> Variant:
if not field is PackedByteArray or (field as PackedByteArray).is_empty():
return null
var img := Image.new()
var err: int = img.load_png_from_buffer(field)
if err != OK:
push_warning("StepCanvasTerrainLayer: PNG plane decode failed (err %d)" % err)
return null
return img
## T-1161's surviving per-rung filter ruling (ticket: "_filter_for_
## granularity_v2... is wired"), repointed at the D-255(a) rung vocabulary.
static func _filter_for_rung(rung: String) -> CanvasItem.TextureFilter:
if StepCanvasTransport.is_orbital_rung(rung):
return CanvasItem.TEXTURE_FILTER_NEAREST
return CanvasItem.TEXTURE_FILTER_LINEAR
func _draw() -> void:
if _texture == null or _footprint_px == Vector2.ZERO:
return
draw_texture_rect(_texture, Rect2(Vector2.ZERO, _footprint_px), false)
## The currently-held canvas's on-screen footprint (px) — the annotation
## layer's world->screen projection targets this SAME rect (D-255(a):
## both layers agree on placement by construction, not reconciliation).
func get_footprint_px() -> Vector2:
return _footprint_px
func get_held_rung() -> String:
return _held_rung
func has_texture() -> bool:
return _texture != null
@@ -0,0 +1,243 @@
extends RefCounted
## Pure stepped-transport geometry for the D-255(a) six-rung Atlas ladder
## (T-1182). This is the FULL replacement for the retired
## `_canvas.scale`/`_view_zoom` continuous-zoom model — there is no float
## zoom here at all, only a discrete rung INDEX (0-5) and integer world-metre
## step math. Every function is a pure function of its arguments (no Node/
## scene-tree dependency), matching this cluster's existing
## atlas_window_geometry.gd discipline, so the transport state machine is
## directly unit-testable.
##
## D-255(a) ladder, rung index -> D-243 gridunit spacing (metres). Rung 0
## (Global) has no single spacing float in the same sense as the fixed rungs
## (its gridunit is "one region" but its CANVAS EXTENT floats per body) — see
## RUNG_SPACING_M's own doc.
const RUNG_GLOBAL: String = "Global"
const RUNG_REGION: String = "Region"
const RUNG_DISTRICT: String = "District"
const RUNG_QUARTER: String = "Quarter"
const RUNG_BLOCK: String = "Block"
const RUNG_CHUNK: String = "Chunk"
## Coarsest (0) to deepest (5) — the SIX levels D-255(a) names, index IS the
## rung. scroll_step()/rung_at_index() below are the only places this table
## is walked; every other consumer works in terms of the rung NAME (matching
## the wire's own bare-string vocabulary, never a raw integer sent over the
## bridge).
const RUNG_LADDER: Array = [
RUNG_GLOBAL, RUNG_REGION, RUNG_DISTRICT, RUNG_QUARTER, RUNG_BLOCK, RUNG_CHUNK
]
## D-243 gridunit spacing in metres for every FIXED rung — mirrors
## server/src/atlas/scale.rs's own constants exactly (REGION_M/DISTRICT_M/
## QUARTER_M/BLOCK_M/CHUNK_M), so the client's rung table can never silently
## drift from the wire contract it's choosing between. Global has no single
## spacing value in the fixed sense (D-255(a): "a Global gridunit and a
## Region gridunit are both 'one region' wide" — step_canvas.rs's own
## StepCanvasRung::spacing_m() returns REGION_M for Global too, "a
## harmless-but-correct value... so ordering/comparison call sites... get a
## sane, documented number rather than 0 or a panic") — mirrored here for the
## same reason.
const RUNG_SPACING_M: Dictionary = {
RUNG_GLOBAL: 204_800.0,
RUNG_REGION: 204_800.0,
RUNG_DISTRICT: 2_048.0,
RUNG_QUARTER: 512.0,
RUNG_BLOCK: 128.0,
RUNG_CHUNK: 64.0,
}
## D-255(a) display-ratio band: screen-px per gridunit. 1x1 at the deep/mid
## rungs (Stig round-2 §(d): "crisper display costs nothing extra... no cost
## reason to fall back to 5x5 at the steps where fidelity matters most"), the
## ~5x5 fallback earning its keep only at the shallow/orbital end where
## canvas EXTENT (not spacing) is what's growing. This is PURELY a
## presentation parameter (D-255(a)/D-243 gridunit amendment: "a free,
## client-side, viewport-dependent parameter, kept architecturally separate
## from gridunit spacing") — it never touches a cache key or a wire request,
## only how many screen px one already-fetched gridunit occupies.
const DISPLAY_RATIO_DEEP: float = 1.0
const DISPLAY_RATIO_SHALLOW: float = 5.0
## Global/Region read at the shallow ratio (orbital-scale canvases — extent
## is what dominates, not per-cell fidelity); District..Chunk read at 1x1
## (D-255(a): "the deep, ground-level steps where the player is closest to
## visible detail"). Chunk's own "1 screen px per 64 m gridunit, no
## magnification margin" bottom-out rule (D-255(a)) is exactly DISPLAY_RATIO_DEEP.
const DISPLAY_RATIO_BY_RUNG: Dictionary = {
RUNG_GLOBAL: DISPLAY_RATIO_SHALLOW,
RUNG_REGION: DISPLAY_RATIO_SHALLOW,
RUNG_DISTRICT: DISPLAY_RATIO_DEEP,
RUNG_QUARTER: DISPLAY_RATIO_DEEP,
RUNG_BLOCK: DISPLAY_RATIO_DEEP,
RUNG_CHUNK: DISPLAY_RATIO_DEEP,
}
## Fixed-rung canvas pixel budget (D-255(a)/(b): "the fixed 3840x2160 px
## budget... every step except Global") — mirrors
## server/src/atlas/step_canvas.rs's STEP_CANVAS_MAX_EXTENT_AXIS/
## STEP_CANVAS_MAX_EXTENT_CELLS ceiling. The client requests THIS extent
## (subject to viewport-fit shrinking, see viewport_fit_extent()) for every
## fixed rung; Global's extent is never read from this constant at all (the
## server derives it from the body's own region grid — the client sends
## SOME extent value per the wire's unconditional field, but it is IGNORED
## server-side, per step_canvas_protocol.gd's own doc).
const FIXED_CANVAS_MAX_AXIS: int = 3_840
## The rung name at ladder index `i`, clamped to the legal [0, 5] range —
## the one place RUNG_LADDER is indexed into, so a caller passing an
## out-of-range index (a scroll past either end) gets the nearest legal rung
## rather than an array-bounds error.
static func rung_at_index(index: int) -> String:
var clamped: int = clampi(index, 0, RUNG_LADDER.size() - 1)
return RUNG_LADDER[clamped]
## The ladder index for a rung name, or -1 if unrecognized (defensive — every
## real caller passes a RUNG_* constant, but an unrecognized wire echo must
## never silently alias to index 0/Global).
static func index_for_rung(rung: String) -> int:
return RUNG_LADDER.find(rung)
## Scroll one notch: `direction` > 0 descends (coarser -> finer, e.g.
## Region -> District), < 0 ascends (finer -> coarser). Clamped at both ends
## — scrolling past Chunk stays at Chunk, scrolling past Global stays at
## Global (the "full-zoom-out reset" is a SEPARATE explicit action, not a
## side effect of this function — see StepCanvasViewer's own reset-to-Global
## handling).
static func scroll_step(current_index: int, direction: int) -> int:
var delta: int = 1 if direction > 0 else (-1 if direction < 0 else 0)
return clampi(current_index + delta, 0, RUNG_LADDER.size() - 1)
static func spacing_for_rung(rung: String) -> float:
return float(RUNG_SPACING_M.get(rung, RUNG_SPACING_M[RUNG_DISTRICT]))
static func display_ratio_for_rung(rung: String) -> float:
return float(DISPLAY_RATIO_BY_RUNG.get(rung, DISPLAY_RATIO_DEEP))
## True for the two rungs that ride derive_orbital_at_metres server-side
## (Global/Region, step_canvas.rs's own StepCanvasRung::uses_orbital_derive())
## — mirrored here purely for READABILITY at call sites that branch on it
## (e.g. "does this rung's canvas ever carry courses" — Global/Region never
## do, matching invent_courses_for_canvas()'s own early return), not because
## the client makes any derivation decision itself (D-255(e): derivation
## stays server-side, full stop).
static func is_orbital_rung(rung: String) -> bool:
return rung == RUNG_GLOBAL or rung == RUNG_REGION
## World-metre HALF-EXTENT (radius from center to edge) a fixed-rung canvas
## of `extent_cells` x `extent_cells` covers, given the rung's own gridunit
## spacing — the request-sizing half of the cursor-anchored step math.
static func half_extent_m(rung: String, extent_cells: int) -> float:
return float(extent_cells) * 0.5 * spacing_for_rung(rung)
## Cursor-anchored step center (D-255(a)/(e), the workshop's own "the center
## the new canvas should be requested around is the world point under the
## cursor" rule): given the CURRENT step's world transform (center in world
## metres, spacing, canvas cell extent, display ratio) and a cursor position
## in canvas-local px (canvas-local = the terrain layer's own local space,
## origin at the canvas's top-left, BEFORE any screen offset), returns the
## world-metre point under the cursor. This is the point the NEXT step's
## request should center on — computed once per scroll notch, not per frame.
static func canvas_local_to_world_m(
canvas_local: Vector2, world_center: Vector2, rung: String, extent_cells: Vector2i
) -> Vector2:
var spacing: float = spacing_for_rung(rung)
var ratio: float = display_ratio_for_rung(rung)
var px_per_gridunit: float = maxf(ratio, 0.0001)
var half_w_m: float = float(extent_cells.x) * 0.5 * spacing
var half_h_m: float = float(extent_cells.y) * 0.5 * spacing
var local_m: Vector2 = canvas_local / px_per_gridunit * spacing
return Vector2(
world_center.x - half_w_m + local_m.x, world_center.y - half_h_m + local_m.y
)
## Inverse of canvas_local_to_world_m() — world metres -> this step's
## canvas-local px (the terrain/annotation layers' shared world->screen
## projection, D-255(e): "texture-to-viewport resize" is the one sanctioned
## display-time scale, this is that same linear map applied to a point
## rather than a texture).
static func world_m_to_canvas_local(
world_m: Vector2, world_center: Vector2, rung: String, extent_cells: Vector2i
) -> Vector2:
var spacing: float = spacing_for_rung(rung)
var ratio: float = display_ratio_for_rung(rung)
var px_per_gridunit: float = maxf(ratio, 0.0001)
var half_w_m: float = float(extent_cells.x) * 0.5 * spacing
var half_h_m: float = float(extent_cells.y) * 0.5 * spacing
var local_m: Vector2 = Vector2(world_m.x - world_center.x + half_w_m, world_m.y - world_center.y + half_h_m)
return local_m / spacing * px_per_gridunit
## The on-screen footprint (px) of a fixed-rung canvas at its display ratio —
## `extent_cells * display_ratio`. The terrain layer draws its texture into
## exactly this Rect2 size; the annotation layer's world->screen projection
## targets the same footprint so both layers agree on where a world point
## lands, by construction (no separate reconciliation step).
static func canvas_footprint_px(rung: String, extent_cells: Vector2i) -> Vector2:
var ratio: float = display_ratio_for_rung(rung)
return Vector2(extent_cells) * ratio
## Fit a fixed-rung request's extent (in gridunits) to the viewport, capped
## at FIXED_CANVAS_MAX_AXIS per axis (D-255(a)/(b)'s own budget) and at the
## rung's own display ratio — this is the CLIENT's half of "viewport-sized
## canvas" (Dudley's server-side policy is the derivation-cost/D-226(d)
## argument; this is the client not requesting more than it can display).
## `viewport_px` is the on-screen area to fill; the request extent in
## GRIDUNITS is `viewport_px / display_ratio`, clamped to
## [1, FIXED_CANVAS_MAX_AXIS] per axis — the server clamps independently too
## (never trust the echo to equal the request), see StepCanvasViewer's own
## "read the echoed extent" discipline.
static func viewport_fit_extent(viewport_px: Vector2, rung: String) -> Vector2i:
var ratio: float = maxf(display_ratio_for_rung(rung), 0.0001)
var cells: Vector2 = viewport_px / ratio
var w: int = clampi(int(ceil(cells.x)), 1, FIXED_CANVAS_MAX_AXIS)
var h: int = clampi(int(ceil(cells.y)), 1, FIXED_CANVAS_MAX_AXIS)
return Vector2i(w, h)
## WASD + arrow keys, read via Input.is_key_pressed() on the PHYSICAL keycode
## (not an InputMap action) — same rationale the retired
## atlas_window_geometry.gd's own held_pan_direction() documented: this
## project's global InputMap already binds W/S/A/D to gameplay movement
## actions, so reading the raw physical keycode keeps this screen's pan
## input independent of whatever gameplay's own action bindings are. Returns
## a raw (non-normalized) direction — the caller normalizes once after
## adding the edge-scroll contribution.
static func held_pan_direction() -> Vector2:
var direction := Vector2.ZERO
if Input.is_key_pressed(KEY_W) or Input.is_key_pressed(KEY_UP):
direction.y -= 1.0
if Input.is_key_pressed(KEY_S) or Input.is_key_pressed(KEY_DOWN):
direction.y += 1.0
if Input.is_key_pressed(KEY_A) or Input.is_key_pressed(KEY_LEFT):
direction.x -= 1.0
if Input.is_key_pressed(KEY_D) or Input.is_key_pressed(KEY_RIGHT):
direction.x += 1.0
return direction
## Snap a world-metre point to the rung's own gridunit grid — the request
## `center` a fixed-rung StepCanvasRequest sends should land on a grid line
## so repeated requests at "the same spot" produce the identical cache key
## (D-227: a canvas for a fixed seed never changes, so exact-repeat cache
## hits are the common case worth protecting). Global ignores center
## entirely server-side (step_canvas_protocol.gd's own doc) so snapping is a
## harmless no-op there.
static func snap_to_gridunit(world_m: Vector2, rung: String) -> Vector2i:
var spacing: float = spacing_for_rung(rung)
if spacing <= 0.0:
return Vector2i(int(round(world_m.x)), int(round(world_m.y)))
return Vector2i(
int(round(world_m.x / spacing)) * int(spacing), int(round(world_m.y / spacing)) * int(spacing)
)
@@ -0,0 +1,523 @@
class_name StepCanvasViewer
extends Control
## The stepped Atlas ladder viewer (T-1182, D-255) — replaces
## AtlasWindowViewer/the `_canvas.scale` continuous-zoom model wholesale.
## "Global opener is rung 0, one viewer one path" (D-255(a) design premise,
## carried verbatim from the ticket): there is no separate orbital-mosaic
## viewer and windowed-drill-down viewer — every rung, including the Global
## body-surface opener, is served by this ONE Control through the SAME
## StepCanvasRequest/Response tagged envelope (T-1181).
##
## Structure (Stig round-1 §1, "naturally three pieces again"):
## - THIS Control: input/pan, rung-transport orchestration, chrome.
## - StepCanvasTerrainLayer (Node2D child of `_canvas`): RTT terrain,
## texel-exact, drawn at the rung's display ratio.
## - StepCanvasAnnotationLayer (Node2D child of `_canvas`, drawn AFTER the
## terrain layer): unscaled screen-space courses/settlement markers.
## Both layers live under ONE `_canvas` Node2D whose `.position` is the pan
## offset ONLY — there is no `.scale` write anywhere in this file (the whole
## point of the retirement: "there is no more zoom-scaled canvas").
##
## Stepped transport (D-255(a)): a discrete rung INDEX (0-5,
## StepCanvasTransport.RUNG_LADDER), never a float zoom. Mouse wheel scrolls
## one rung notch per detent, cursor-anchored (the world point under the
## cursor becomes the next step's request center — Stig round-1 §2).
## Edge-scroll/WASD pan within a held rung; panning past the held canvas's
## own edge re-requests the SAME rung at a new center (mirrors the retired
## viewer's own pan-edge refetch, just against the new wire). A hard
## zoom-out past rung 0 resets to the Global frame (D-255(a)'s "hard
## full-zoom-out reset").
##
## Hold-fetch-swap (Stig round-1 §2, the shipped baseline — morph/tween is
## an optional cosmetic follow-on, NOT built here per the ticket): on a
## scroll-step, the CURRENT step's texture stays displayed, unscaled, while
## the new step's request is in flight (StepCanvasTerrainLayer simply isn't
## rebuilt until the new canvas arrives — "hold" is the ABSENCE of a
## premature rebuild, not a separate code path).
signal back_pressed
const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd")
const StepCanvasTerrainLayer := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_terrain_layer.gd")
const StepCanvasAnnotationLayer := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_annotation_layer.gd")
const StepCanvasRequest := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_request.gd")
const PANEL_MARGIN: float = 16.0
const OVERLAY_BAR_HEADER_RESERVE: float = 360.0
const PAN_SPEED_PX_S: float = 220.0
const EDGE_SCROLL_MARGIN_PX: float = 24.0
const COLOR_BG: Color = Color("#0d1117")
const COLOR_BORDER_FADE: Color = Color(0.20, 0.24, 0.30, 0.55)
const COLOR_PENDING_WASH: Color = Color(0.20, 0.24, 0.30, 0.12)
const COLOR_DERIVING_LABEL: Color = Color("#667788")
const OVERLAY_DEFS: Array = [
{
"id": "gen_dw_temp",
"label": "TMP",
"group": "toggle",
"tooltip": "Temperature — region-ramp colorizer."
},
{
"id": "gen_dw_moisture",
"label": "MST",
"group": "toggle",
"tooltip": "Moisture — dry-to-wet ramp."
},
{
"id": "gen_dw_veg",
"label": "VEG",
"group": "toggle",
"tooltip": "Vegetation — green-family ramp. Marine reads transparent."
},
]
# ── Context (set by enter()) ──────────────────────────────────────────────
var _body: Dictionary = {}
var _system: Dictionary = {}
var _implant_theme = null
# ── Rung transport state ──────────────────────────────────────────────────
var _rung_index: int = 0 # 0 == Global, the entry rung (D-255(a))
var _world_center: Vector2 = Vector2.ZERO
var _held_rung: String = StepCanvasTransport.RUNG_GLOBAL
var _held_extent: Vector2i = Vector2i.ZERO
# ── Pan state (position only — NO scale/zoom field anywhere) ─────────────
var _view_offset: Vector2 = Vector2.ZERO
var _last_mouse_pos: Vector2 = Vector2(-1.0, -1.0)
var _app_has_focus: bool = true
# ── Overlay visibility ─────────────────────────────────────────────────────
var _overlay_visibility: Dictionary = {}
# ── Child nodes ────────────────────────────────────────────────────────────
var _canvas: Node2D = null
var _terrain_layer: StepCanvasTerrainLayer = null
var _annotation_layer: StepCanvasAnnotationLayer = null
var _screen_header: ImplantHeader = null
var _overlay_bar = null
var _legend_panel = null
var _request = null # StepCanvasRequest
func _ready() -> void:
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = Control.GROW_DIRECTION_BOTH
grow_vertical = Control.GROW_DIRECTION_BOTH
mouse_filter = Control.MOUSE_FILTER_STOP
focus_mode = Control.FOCUS_ALL
_implant_theme = load("res://ui/implant/default_implant.tres")
for def: Dictionary in OVERLAY_DEFS:
_overlay_visibility[def["id"]] = false
_canvas = Node2D.new()
_canvas.name = "StepCanvas"
add_child(_canvas)
_terrain_layer = StepCanvasTerrainLayer.new()
_terrain_layer.name = "TerrainLayer"
_canvas.add_child(_terrain_layer)
_annotation_layer = StepCanvasAnnotationLayer.new()
_annotation_layer.name = "AnnotationLayer"
_canvas.add_child(_annotation_layer)
_request = StepCanvasRequest.new(self)
_request.name = "Request"
add_child(_request)
_request.canvas_ready.connect(_on_canvas_ready)
_build_screen_header()
_build_overlay_bar()
_build_legend_panel()
SimBridge.step_canvas_received.connect(_on_step_canvas_received)
func _exit_tree() -> void:
if SimBridge.step_canvas_received.is_connected(_on_step_canvas_received):
SimBridge.step_canvas_received.disconnect(_on_step_canvas_received)
## Enter the ladder at the Global opener (rung 0) — the sole entry point
## (D-255(a): "Global opener is rung 0, one viewer one path"). Replaces both
## the retired enter()/enter_orbital() split — there is no District-rung
## direct-entry variant anymore, since descent from Global is a continuous
## scroll gesture, not a nav-stack choice.
func enter(body: Dictionary, system: Dictionary) -> void:
_body = body
_system = system
_rung_index = 0
_world_center = Vector2.ZERO
_held_rung = StepCanvasTransport.RUNG_GLOBAL
_held_extent = Vector2i.ZERO
_view_offset = Vector2.ZERO
_request.reset()
_annotation_layer.clear_frame()
_fire_request()
_refresh_screen_header()
if _legend_panel:
_legend_panel.refresh()
grab_focus()
queue_redraw()
func leave() -> void:
pass
func get_body_id() -> String:
return _dict_str(_body, "body_id", "")
func get_held_rung() -> String:
return _held_rung
func is_overlay_visible(overlay_id: String) -> bool:
return bool(_overlay_visibility.get(overlay_id, false))
func set_overlay_visible(overlay_id: String, visible_state: bool) -> void:
if not _overlay_visibility.has(overlay_id):
push_warning("StepCanvasViewer: unknown overlay id '%s'" % overlay_id)
return
_overlay_visibility[overlay_id] = visible_state
_rebuild_terrain_texture()
if _legend_panel:
_legend_panel.refresh()
func get_overlay_defs() -> Array:
return OVERLAY_DEFS
# =============================================================================
# Request lifecycle
# =============================================================================
## Fire the current step's request — cursor-anchored center + viewport-fit
## extent for a fixed rung; Global sends the same fields unconditionally
## (ignored server-side, per step_canvas_protocol.gd's own doc).
func _fire_request() -> void:
var extent: Vector2i = _request_extent()
var center: Vector2i = StepCanvasTransport.snap_to_gridunit(_world_center, _held_rung)
_request.request_now(get_body_id(), _held_rung, center, extent)
func _request_extent() -> Vector2i:
var viewport: Vector2 = get_rect().size
if viewport == Vector2.ZERO:
viewport = Vector2(1280.0, 720.0)
return StepCanvasTransport.viewport_fit_extent(viewport, _held_rung)
func _on_step_canvas_received(response: Dictionary) -> void:
_request.on_response(response)
## A canvas is ready (cache hit OR a fresh Ready response) — adopt it. This
## is the ONE place the terrain/annotation layers are told to rebuild; until
## this fires, the layers keep showing whatever they already held (the
## hold-fetch-swap contract — Stig round-1 §2).
func _on_canvas_ready(canvas: Dictionary) -> void:
_held_extent = _request.get_held_extent()
_rebuild_terrain_texture(canvas)
_annotation_layer.set_frame(canvas, _world_center, _held_rung, _held_extent)
_refresh_screen_header()
if _legend_panel:
_legend_panel.refresh()
queue_redraw()
func _rebuild_terrain_texture(canvas: Variant = null) -> void:
var c: Variant = canvas if canvas != null else _terrain_layer._canvas_ref
if not c is Dictionary:
return
_terrain_layer.rebuild_from_canvas(c, _held_rung, _active_toggle_overlay())
func _active_toggle_overlay() -> String:
if is_overlay_visible("gen_dw_temp"):
return "gen_dw_temp"
if is_overlay_visible("gen_dw_moisture"):
return "gen_dw_moisture"
if is_overlay_visible("gen_dw_veg"):
return "gen_dw_veg"
return ""
# =============================================================================
# Rung transport — cursor-anchored scroll step, edge-crossing pan re-request
# =============================================================================
## One scroll-wheel notch: `direction` > 0 descends (coarser -> finer),
## < 0 ascends. Cursor-anchored (D-255(a)/Stig round-1 §2): the world point
## under the cursor becomes the new step's request center, so the player's
## point of interest stays put across the crossing.
func _scroll_rung(direction: int, cursor_local: Vector2) -> void:
var cursor_world: Vector2 = StepCanvasTransport.canvas_local_to_world_m(
cursor_local - _view_offset, _world_center, _held_rung, _held_extent
)
var new_index: int = StepCanvasTransport.scroll_step(_rung_index, direction)
if new_index == _rung_index:
return
_rung_index = new_index
_held_rung = StepCanvasTransport.rung_at_index(_rung_index)
_world_center = cursor_world if _held_rung != StepCanvasTransport.RUNG_GLOBAL else Vector2.ZERO
_view_offset = Vector2.ZERO
_fire_request()
_refresh_screen_header()
queue_redraw()
## Hard reset to the Global opener (D-255(a): "a hard full-zoom-out reset to
## the canonical Global body-surface frame").
func _reset_to_global() -> void:
if _rung_index == 0:
return
_rung_index = 0
_held_rung = StepCanvasTransport.RUNG_GLOBAL
_world_center = Vector2.ZERO
_view_offset = Vector2.ZERO
_fire_request()
_refresh_screen_header()
queue_redraw()
## Pan-edge re-request: once the view has panned far enough that the held
## canvas's own edge would show, re-request the SAME rung at a new center —
## mirrors the retired viewer's own _maybe_refloat_window(), against the new
## wire's request shape. Global never re-requests on pan (its canvas is the
## whole body, D-255(a) — no edge to cross).
func _maybe_refloat() -> void:
if _held_rung == StepCanvasTransport.RUNG_GLOBAL:
return
var footprint: Vector2 = _terrain_layer.get_footprint_px()
if footprint == Vector2.ZERO:
return
var half: Vector2 = footprint * 0.5
# view_offset is the SCREEN position of canvas-local (0,0) — the canvas's
# center in canvas-local space is `half`. Once panning has moved that
# point more than half the footprint away from screen-center, the edge
# is at or past the viewport's own center — time to re-float.
var screen_center: Vector2 = get_rect().size * 0.5
var canvas_center_screen: Vector2 = _view_offset + half
var drift: Vector2 = canvas_center_screen - screen_center
if absf(drift.x) < half.x * 0.5 and absf(drift.y) < half.y * 0.5:
return
var new_center_world: Vector2 = StepCanvasTransport.canvas_local_to_world_m(
screen_center - _view_offset, _world_center, _held_rung, _held_extent
)
_world_center = new_center_world
_view_offset = Vector2.ZERO
_fire_request()
# =============================================================================
# Drawing
# =============================================================================
func _draw() -> void:
draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG)
if not _terrain_layer.has_texture():
_draw_border_fade()
elif _request.is_pending():
_draw_pending_wash()
func _draw_border_fade() -> void:
draw_rect(Rect2(_view_offset, get_rect().size), COLOR_BORDER_FADE)
_draw_deriving_label()
func _draw_pending_wash() -> void:
draw_rect(Rect2(_view_offset, get_rect().size), COLOR_PENDING_WASH)
func _draw_deriving_label() -> void:
var label := "DERIVING TERRAIN…"
var font := ThemeDB.fallback_font
var font_size := 20
var text_size: Vector2 = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
var viewport: Vector2 = get_rect().size
var center: Vector2 = viewport * 0.5
var baseline: Vector2 = center - text_size * 0.5 + Vector2(0.0, text_size.y * 0.5)
draw_string(
font, baseline, label, HORIZONTAL_ALIGNMENT_CENTER, -1, font_size, COLOR_DERIVING_LABEL
)
func _apply_transform() -> void:
_canvas.position = _view_offset
queue_redraw()
# =============================================================================
# Chrome
# =============================================================================
func _build_screen_header() -> void:
_screen_header = ImplantHeader.new()
_screen_header.position = Vector2(PANEL_MARGIN, 16.0)
_screen_header.custom_minimum_size.x = 320.0
_screen_header.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_screen_header)
if _implant_theme:
_screen_header.apply_implant_theme(_implant_theme)
func _refresh_screen_header() -> void:
if _screen_header == null:
return
var name_label: String = _dict_str(_body, "proper_name", _dict_str(_body, "body_id", ""))
var spacing_km: float = StepCanvasTransport.spacing_for_rung(_held_rung) / 1000.0
var title := "ATLAS — %s" % name_label.to_upper()
var subtitle := "%s · %.3f km/gridunit" % [_held_rung.to_upper(), spacing_km]
_screen_header.set_content(title, subtitle)
func _build_overlay_bar() -> void:
var BarScript := load("res://ui/implant/apps/atlas/atlas_overlay_bar.gd")
_overlay_bar = BarScript.new(self)
_overlay_bar.name = "OverlayBar"
add_child(_overlay_bar)
_position_overlay_bar()
func _position_overlay_bar() -> void:
var sz: Vector2 = get_rect().size
if sz == Vector2.ZERO:
sz = Vector2(1280.0, 720.0)
var avail_w: float = maxf(sz.x - OVERLAY_BAR_HEADER_RESERVE - PANEL_MARGIN * 2.0, 200.0)
_overlay_bar.position = Vector2(sz.x - avail_w - PANEL_MARGIN, PANEL_MARGIN)
_overlay_bar.size = Vector2(avail_w, 0.0)
func _build_legend_panel() -> void:
var LegendScript := load("res://ui/implant/apps/atlas/step_canvas/step_canvas_legend.gd")
_legend_panel = LegendScript.new(self)
_legend_panel.name = "Legend"
_legend_panel.theme_resource = _implant_theme
add_child(_legend_panel)
_legend_panel.refresh()
func _notification(what: int) -> void:
if what == NOTIFICATION_RESIZED:
if _overlay_bar:
_position_overlay_bar()
if _legend_panel:
_legend_panel.reposition()
elif what == NOTIFICATION_APPLICATION_FOCUS_OUT:
_app_has_focus = false
elif what == NOTIFICATION_APPLICATION_FOCUS_IN:
_app_has_focus = true
# =============================================================================
# Input
# =============================================================================
func _is_over_ui(_pos: Vector2) -> bool:
return false
func _gui_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.is_echo():
_handle_key(event as InputEventKey)
return
if (
event is InputEventMouseButton
and _is_over_ui((event as InputEventMouseButton).global_position)
):
return
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed:
_scroll_rung(1, mb.position)
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed:
_scroll_rung(-1, mb.position)
elif event is InputEventMouseMotion:
_last_mouse_pos = (event as InputEventMouseMotion).position
func _handle_key(event: InputEventKey) -> void:
if event.keycode == KEY_ESCAPE:
back_pressed.emit()
func _process(delta: float) -> void:
if not visible:
return
var direction: Vector2 = StepCanvasTransport.held_pan_direction()
if _is_cursor_edge_scrolling():
direction += _edge_scroll_direction()
if direction == Vector2.ZERO:
return
_apply_pan_delta(direction, delta)
func _apply_pan_delta(direction: Vector2, delta: float) -> void:
var normalized: Vector2 = direction.normalized()
_view_offset -= normalized * PAN_SPEED_PX_S * delta
_apply_transform()
_maybe_refloat()
func _is_cursor_edge_scrolling() -> bool:
if not _app_has_focus or _is_over_ui(_last_mouse_pos):
return false
var viewport: Vector2 = size
if viewport.x <= 0.0 or viewport.y <= 0.0:
return false
return (
_last_mouse_pos.x >= 0.0
and _last_mouse_pos.y >= 0.0
and _last_mouse_pos.x <= viewport.x
and _last_mouse_pos.y <= viewport.y
and (
_last_mouse_pos.x < EDGE_SCROLL_MARGIN_PX
or _last_mouse_pos.y < EDGE_SCROLL_MARGIN_PX
or _last_mouse_pos.x > viewport.x - EDGE_SCROLL_MARGIN_PX
or _last_mouse_pos.y > viewport.y - EDGE_SCROLL_MARGIN_PX
)
)
func _edge_scroll_direction() -> Vector2:
var viewport: Vector2 = size
var direction := Vector2.ZERO
if _last_mouse_pos.x < EDGE_SCROLL_MARGIN_PX:
direction.x -= 1.0
elif _last_mouse_pos.x > viewport.x - EDGE_SCROLL_MARGIN_PX:
direction.x += 1.0
if _last_mouse_pos.y < EDGE_SCROLL_MARGIN_PX:
direction.y -= 1.0
elif _last_mouse_pos.y > viewport.y - EDGE_SCROLL_MARGIN_PX:
direction.y += 1.0
return direction
static func _dict_str(d: Dictionary, key: String, fallback: String) -> String:
var v: Variant = d.get(key)
if v == null:
return fallback
var s: String = str(v)
if s.is_empty():
return fallback
return s