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:
@@ -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))
|
||||
+21
-42
@@ -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
|
||||
Reference in New Issue
Block a user