fix(client): T-1153/T-1152 round 3 — unified rung coverage model, reselect clamp mirror, orbital tile-set mosaic

Bug A (zoom reselection dead): select_rung() redesigned from the two-gate
split to a unified per-rung coverage-ceiling walk (MAX_COVERAGE_M,
finest-first) — which also restores District as a reachable rung
(33-131km extent band; the two-gate model had made it structurally
unreachable). Plus the THIRD instance of the PR #191 C1 clamp-mirror bug
class: _maybe_reselect_rung fed the old rung's _held_n raw into the new
rung's request — the server clamped, the held value didn't, every
cross-rung response stale-dropped. Re-clamped at the boundary; the
leaving_tile_mode flag also fixes a stale request-granularity edge case.

Bug B (orbital = whole body): genuine tile-set model. compute_tile_grid
composes ceil-divided Region-tile centers (canonicalized column wrap,
row clamp, Dictionary-set dedup; Lendel = 3x2 = 6 tiles);
AtlasWindowTileSet owns one AtlasWindowRequest per tile (reusing all
tested request/cache/debounce machinery), fans responses out, per-tile
tile_ready — genuinely progressive; the overlay draws arrived tiles as a
mosaic under the one view transform. enter_orbital branches to tile mode
when >1 tile; single-window path unchanged below the coverage ceiling.
E/W wrap at tile seams live-confirmed (col -6400 wraps to 12739). Pole-
row dedup verified correct but flagged honestly as currently-unreachable
defensive logic (no real radius triggers row collision).

All fixes + fallout verified via revert/confirm-fails/restore. Targeted
suites 256/256; gdlint clean; new 20-test tile-set suite.
This commit is contained in:
2026-07-22 13:03:38 +02:00
parent 11f7927f24
commit 6195fd28b3
8 changed files with 1330 additions and 533 deletions
@@ -15,7 +15,14 @@ extends RefCounted
## 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.
## 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).
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
@@ -40,11 +47,66 @@ const RUNG_TABLE: Array = [
## 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). select_rung() uses this
## to answer "CAN a District-granularity window even cover this much world at
## all" — the coarse-end ceiling, distinct from the fine-end `2x` tolerance.
## 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
@@ -160,90 +222,61 @@ static func clamp_pan_offset_to_pole_wall(
# =============================================================================
## Rung-selection rule (design doc §5, restated): for a world extent `E`
## metres shown across canvas `C` px, sample spacing is `E/C`. Select the
## COARSEST rung whose cell spacing is `<= 2*(E/C)` — one tier finer than a
## screen pixel, never coarser (never magnified-interpolation of a coarser
## composite, the literal thing the D-166 corollary forbids). This `2x`
## tolerance gates the FINE end only (District vs. Quarter) — see the
## coarse-end paragraph below for why Region is decided by a DIFFERENT test.
## 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).
##
## **Region is selected by a COVERAGE test, not the `2x` visual tolerance**
## (found live-testing the orbital-entry fit zoom, where E covers a whole
## planetary circumference). The `2x` formula is calibrated to catch the
## ZOOM-IN failure mode the corollary names explicitly — never request
## coarser derivation than the screen can currently resolve — and has no
## meaningful symmetric zoom-OUT reading: testing Region's own 204.8 km
## spacing against the SAME threshold that gates District/Quarter would
## reject Region at essentially every normal screen resolution (a whole-body
## view's sample spacing is tens of km/px, and 2x that is still far under
## 204.8 km — even though visually a ~5-10-screen-px-per-cell Region view
## reads perfectly fine, nowhere near "magnified interpolation"). The
## GENUINELY load-bearing question at the coarse end is different: can a
## District-granularity window (capped server-side at
## DISTRICT_WINDOW_MAX_N=64 districts, ~131 km per side) physically COVER
## the extent being displayed at all? Once it can't, Region is the only rung
## that CAN — this is a coverage/capacity fact, not a resolution-legibility
## judgment, and it's what actually decides "zoom out past the district rung
## transitions to Region" per the ticket's own framing.
##
## **A third finding, resolving the above two against each other:** at
## CELL_PIXEL_SIZE=16 (the shipped display scale), the fine-end `2x` band
## that would select District and the coarse-end coverage ceiling that
## selects Region do not meet — District's OWN native resolution already
## reads as "too fine" (wants Quarter) well before its 64-district coverage
## cap becomes binding (wants Region), leaving NO zoom range where the `2x`
## formula alone would ever pick District. Since the coverage ceiling is a
## hard CAPABILITY limit (a District request literally cannot serve more
## world than its per-axis cap covers) while the `2x` band is a QUALITY
## preference (finer than strictly needed is wasteful, not wrong), the
## coverage ceiling wins whenever the two disagree: check it FIRST, and only
## consult the `2x` band to choose between District and Quarter for whatever
## extent remains under that ceiling. This is a genuine engineering call this
## implementation makes (flagged to the team, not a design-doc-literal
## derivation) — see docs/architecture/atlas-zoom-ladder-t1143.md §5 Risk R4
## ("Region rung is named but unscoped") for the open design question this
## resolves pragmatically rather than by further design-pass iteration.
##
## **Whether a District band exists at all is independent of CELL_PIXEL_SIZE**
## — it cancels out of the "does District's `2x` band overlap the coverage
## ceiling" condition entirely. The condition reduces to `canvas_px <=
## DISTRICT_WINDOW_MAX_N * DISTRICT_SPACING_M / 1024` — i.e. `canvas_px <=
## 128px` at the shipped constants. At every real viewport (800px+), this is
## never satisfied: District's band is empty by construction, and the ladder
## in practice steps Region -> Quarter directly at any normal screen size.
## Verified numerically at 1600x900 (canvas_px=1600): the Region/District
## crossing (world_extent_m == the coverage ceiling) sits at `_view_zoom ≈
## 1.5625`, and the District/Quarter crossing (the `2x` band's own edge)
## sits at `_view_zoom ≈ 0.125` — i.e. the `2x` band's own boundary is
## already PAST (a smaller zoom than) where the coverage ceiling releases
## District, so the two never overlap in the zoomed-in direction either.
## **Tuning knobs, if a real District band is wanted:** the ONLY lever that
## opens the gap is `DISTRICT_WINDOW_MAX_N` (currently 64, mirrored from the
## server's own per-axis cap) — it would need to reach `1024 * canvas_px /
## DISTRICT_SPACING_M` (≈800 at a 1600px canvas) to open a band there, a
## substantial server-side wire-size change (T-1150's `WIRE_CAP_CELLS`
## budget), not a client-only tuning knob. `CELL_PIXEL_SIZE` does NOT affect
## whether a band exists — it only shifts WHERE both crossing zooms sit on
## the wheel gesture (scaling both proportionally, preserving their ~12.5x
## gap), i.e. it is the felt-pacing knob for how much wheel travel separates
## Region from Quarter, not a way to reintroduce District.
## 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:
if world_extent_m > float(DISTRICT_WINDOW_MAX_N) * DISTRICT_SPACING_M:
return "Region" # coverage ceiling — District physically cannot span this much world
if canvas_px <= 0.0:
return "Quarter" # finest — an unlaid-out viewport must under-resolve, not over-resolve
var sample_spacing_m: float = world_extent_m / canvas_px
var threshold_m: float = 2.0 * sample_spacing_m
if DISTRICT_SPACING_M <= threshold_m:
return "District"
return "Quarter" # threshold too small for even District's own spacing -> finest legal rung
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
@@ -438,3 +471,116 @@ static func edge_scroll_direction(
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}
@@ -61,6 +61,8 @@ extends Node2D
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")
## T-1145 item 3: interim presentation toggle — true renders the smoothed
## Image/ImageTexture composite; false keeps the original crisp per-cell
@@ -94,6 +96,9 @@ var _cache_active_toggle: String = ""
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
@@ -132,6 +137,125 @@ func _draw() -> void:
_draw_crisp_composite(w, grid_side, n, cell_px, active_toggle)
## T-1153, live round 3 (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 from the tile-set's reference
## origin (district (0,0), matching `_enter_tile_mode()`'s own
## `_held_center = Vector2i.ZERO`). A tile centered at absolute district
## `tile.center` occupies local canvas space
## `[(tile.center - TILE_N/2) * cell_px, (tile.center + TILE_N/2) * cell_px)`
## — the SAME "window spans [center - n/2, center + n/2)" convention the
## single-window draw path already uses, just evaluated per tile instead of
## once for the whole composite. 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).
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 half_tile: float = float(AtlasWindowGeometryRef.TILE_N) * 0.5
for tile: Dictionary in tile_set.get_tiles():
var window: Variant = tile["window"]
if not window is Dictionary:
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
var center: Vector2i = tile["center"]
var local_origin: Vector2 = Vector2(
(float(center.x) - half_tile) * cell_px, (float(center.y) - half_tile) * cell_px
)
var extent: float = float(AtlasWindowGeometryRef.TILE_N) * cell_px
_draw_one_tile(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 (keyed by the tile's own window
## reference, via `_tile_texture_cache` below) — 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.
func _draw_one_tile(
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 = _build_tile_texture(w, grid_side, active_toggle)
if tile_texture == null:
return
texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
draw_texture_rect(tile_texture, Rect2(local_origin, Vector2(extent, extent)), false)
## Builds (uncached — see the class doc's rebuild-cost paragraph for why the
## SINGLE-window path caches by reference; a per-tile cache keyed the same
## way would need a Dictionary keyed on tile index, a reasonable follow-up
## if mosaic redraw cost ever matters in practice, not attempted here since
## the mosaic is drawn only at the orbital rest state, never mid-interaction
## at a high redraw rate the way single-window zoom/pan is) 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.
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
@@ -0,0 +1,166 @@
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
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 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
@@ -3,74 +3,64 @@ extends Control
## Continuous cursor-anchored zoom ladder viewer (T-1153, superseding T-1138's
## click-through-only entry per the D-226 T-1143-rulings amendment — see
## enter_orbital()'s own doc). This IS the "regional" nav entry now (T-1152
## enter_orbital()'s own doc). This IS the "regional" nav entry (T-1152
## client half): the whole ladder from the canonical orbital frame (Region
## rung) down to District/Quarter granularity lives in ONE screen/Control,
## not a separate planetary heightmap viewer + a windowed drill-down. Renders
## a DistrictWindowLayer composite at whichever rung is currently held:
## morphology base layer lightness-modulated by elev_q, three switchable
## climate/vegetation overlays, and an always-on glaciation ice-tint modifier
## (drawing itself is AtlasWindowOverlay's job — this Control owns input,
## request orchestration, chrome, and the pan/zoom transform). One colorizer
## family renders every rung unchanged (design doc §6) — AtlasWindowOverlay
## never branches on granularity_v2 for COLOR, only for the derived
## cell-grid's RESOLUTION (cell_grid_side_for_window()).
## rung) down to District/Quarter lives in ONE screen/Control, not a separate
## planetary viewer + windowed drill-down. Renders a DistrictWindowLayer
## composite (or, at the orbital rest state on a large body, a MOSAIC of
## several — see `_tile_mode`/AtlasWindowTileSet, live round 3) at whichever
## rung is currently held: morphology base layer lightness-modulated by
## elev_q, three switchable climate/vegetation overlays, an always-on
## glaciation ice-tint modifier (drawing is AtlasWindowOverlay's job — this
## Control owns input, request orchestration, chrome, pan/zoom). One
## colorizer family renders every rung unchanged (design doc §6).
##
## Design notes (mirroring AtlasViewer's own split, D-226 §5, extended T-1153):
## - _canvas (Node2D) holds AtlasWindowOverlay; pan = _canvas.position, zoom
## = _canvas.scale — the SAME transform idiom as the (retired) planetary
## viewer.
## - Zoom is client-side on the ALREADY-HELD composite frame-to-frame (never
## blocks on a re-derive), but is CONTINUOUS AND UNCLAMPED ACROSS RUNGS
## (T-1153, D-013 restored for this seam): crossing a rung's spacing
## threshold (§5 rung-selection rule) fires a background request for the
## new granularity while the OLD composite keeps drawing — progressive
## refinement, no blank frame, no mode flip (§6). A pan past the held
## window's edge re-requests the SAME rung at a new center (§4/§5,
## unchanged from T-1138).
## = _canvas.scale.
## - Zoom is client-side on the ALREADY-HELD composite frame-to-frame, but
## CONTINUOUS AND UNCLAMPED ACROSS RUNGS (D-013 restored for this seam):
## crossing a rung's coverage ceiling (§5, redesigned per live round 3 —
## see AtlasWindowGeometry.select_rung()) fires a background request for
## the new granularity while the OLD composite keeps drawing —
## progressive refinement, no blank frame, no mode flip (§6). A pan past
## the held window's edge re-requests the SAME rung at a new center.
## - Zooming fully out snaps to the CANONICAL planetary frame (Jeroen's HARD
## condition) — see _maybe_reset_to_canonical_frame().
## - _window_request (atlas_window_request.gd) owns the cache/debounce/
## retry — this Control decides WHEN to call it (pan-edge detection,
## rung-reselect, entry), never talks to SimBridge directly itself.
## condition) — see _maybe_reset_to_canonical_frame() — which, on a body
## needing tiling, re-enters `_tile_mode` (live round 3, design doc §4:
## "the top rest state is the WHOLE body, served as progressive
## capped-density TILING").
## - _window_request (atlas_window_request.gd) owns the single-window
## cache/debounce/retry; _tile_set (atlas_window_tile_set.gd) owns N of
## those for the tiled rest state — this Control decides WHICH is active.
##
## Navigation (T-1145 item 2 — Jeroen's input-model ruling: LMB-drag panning
## BREAKS click semantics with map objects, so it is removed entirely; clicks
## are reserved for map objects, which will land in this window later, e.g.
## settlements):
## Navigation (Jeroen's input-model ruling: LMB-drag panning BREAKS click
## semantics with future map objects, so it's removed entirely):
## WASD / arrow keys continuous pan, held (frame-rate independent, _process)
## Edge scrolling cursor within EDGE_SCROLL_MARGIN_PX of a viewport
## edge pans toward it (suppressed over UI / unfocused)
## Mouse wheel cursor-anchored zoom; crosses rungs continuously (T-1153)
## Esc back (nav.pop() — the "district" nav-stack entry is
## gone as a separate hop, see atlas_app.gd's own doc)
## Esc back (nav.pop())
signal back_pressed
const PANEL_MARGIN: float = 16.0
const OVERLAY_BAR_HEADER_RESERVE: float = 360.0
## T-1153: MIN_ZOOM/MAX_ZOOM stay a wide safety clamp on the raw display
## multiplier (never letting _view_zoom collapse to zero or run away toward
## infinity) — they are NOT a rung boundary any more. Wheel zoom is now
## CONTINUOUS and UNCLAMPED ACROSS RUNGS (D-226 T-1143-rulings amendment,
## Jeroen's seam ruling: "D-013's zoom gesture owns spatial descent restored
## for this seam"): crossing a rung's spacing threshold (§5's rung-selection
## rule, AtlasWindowGeometry.select_rung()) re-requests a DIFFERENT
## granularity window at the SAME apparent screen extent, it does not clamp
## _view_zoom itself. The programmatic capture API (set_view(), T-1120) still
## clamps to this same wide range — a capture harness driving a specific
## zoom/offset pair has no rung-crossing concept of its own to trigger.
## T-1153: MIN_ZOOM/MAX_ZOOM are a wide safety clamp on the raw display
## multiplier, NOT a rung boundary — wheel zoom is CONTINUOUS and UNCLAMPED
## ACROSS RUNGS (D-013 restored for this seam): crossing a rung's coverage
## ceiling (AtlasWindowGeometry.select_rung()) re-requests a DIFFERENT
## granularity at the SAME apparent screen extent, never clamping
## _view_zoom itself. set_view() (T-1120 capture API) clamps to this same
## range independently.
##
## MIN_ZOOM must stay low enough that fit_window_view()'s COVER fit for
## enter_orbital()'s largest legal `n` (a whole equatorial circumference, up
## to hundreds of thousands of districts on a gas-giant-scale body) is never
## itself clamped — a clamped fit zoom would silently show LESS than the
## whole body, breaking Jeroen's HARD condition ("the whole body fitted to
## the canvas") at exactly the moment it matters most. 0.0005 covers a
## ~120,000 km-radius body (n≈368,000 districts) at a 3840px 4K viewport with
## headroom; a real fit_zoom this low is expected and correct at the
## canonical orbital frame, not a bug.
## enter_orbital()'s largest legal `n` (up to hundreds of thousands of
## districts on a gas-giant-scale body) is never itself clamped — that would
## silently show LESS than the whole body, breaking Jeroen's HARD condition.
## 0.0005 covers a ~120,000 km-radius body at a 3840px 4K viewport with
## headroom.
const MIN_ZOOM: float = 0.0005
const MAX_ZOOM: float = 64.0
const ZOOM_STEP: float = 1.15
@@ -125,6 +115,8 @@ const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_re
# atlas_descend_geometry.gd instead — it already owns district_extent()).
const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd")
const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd")
# T-1153: orbital rest-state mosaic orchestration.
const AtlasWindowTileSet := preload("res://ui/implant/apps/atlas/atlas_window_tile_set.gd")
# ── Overlay definitions (T-1138 — reuses atlas_overlay_bar.gd/
# atlas_legend_panel.gd's existing duck-typed viewer interface: both call
@@ -213,6 +205,15 @@ var _screen_header: ImplantHeader = null
var _overlay_bar = null
var _legend_panel = null
var _window_request = null # AtlasWindowRequest
var _tile_set = null # AtlasWindowTileSet (T-1153, live round 3)
## T-1153 (Jeroen's ruling, design doc §4): true while showing the orbital
## rest state as a MULTI-WINDOW MOSAIC (AtlasWindowTileSet) instead of the
## single held composite (`_window`). Set by `_enter_tile_mode()` when
## compute_tile_grid() produces more than one tile; cleared the moment
## `_maybe_reselect_rung()` crosses OUT of Region — tiling is purely a
## TOP-of-the-ladder concern, never active below Region.
var _tile_mode: bool = false
func _ready() -> void:
@@ -242,6 +243,11 @@ func _ready() -> void:
add_child(_window_request)
_window_request.window_ready.connect(_on_window_ready)
_tile_set = AtlasWindowTileSet.new(self)
_tile_set.name = "TileSet"
add_child(_tile_set)
_tile_set.tile_ready.connect(_on_tile_ready)
_build_screen_header()
_build_overlay_bar()
_build_legend_panel()
@@ -254,29 +260,20 @@ func _exit_tree() -> void:
SimBridge.atlas_layers_received.disconnect(_on_atlas_layers_received)
## Enter the window screen centered on `district_center` (a DistrictPos-
## equivalent Vector2i, from a click-through's derived position — §5's "pan
## center read as click point") at District granularity. n defaults to the
## client's interactive default (32), half the server's hard cap. Kept as a
## thin District-rung wrapper over _enter_at_rung() (T-1153) — a click-to-
## descend-to-point shortcut on top of the continuous ladder (Jeroen's
## ruling: "if a click-to-descend-to-point remains cheap to keep... wired to
## the same descent path"). No screen currently calls this directly (the
## retired planetary click-through it served no longer exists — see
## atlas_app.gd's own doc); it survives as the landing point a future
## map-object click (e.g. a settlement marker on the Region-rung view) would
## wire into, and as a direct-call entry for tests/tools that want a
## District-rung window without going through enter_orbital() first.
## Enter the window screen centered on `district_center` (a DistrictPos, from
## a click-through's derived position) at District granularity. n defaults to
## 32, half the server's hard cap. Kept as a thin District-rung wrapper over
## _enter_at_rung() (T-1153) — a click-to-descend-to-point shortcut on top of
## the continuous ladder (Jeroen's ruling). No screen currently calls this
## directly (the retired planetary click-through it served no longer exists
## — see atlas_app.gd's own doc); it survives as the landing point a future
## map-object click would wire into, and as a direct-call entry for tests.
##
## T-1142: `district_center` is canonicalized (wrap column / clamp row)
## BEFORE it becomes `_held_center` or reaches the request — matching the
## server's own normalize_window_center() exactly, so the request the client
## sends and the echo the server sends back describe the SAME canonical
## point from the first round-trip (never a raw-vs-normalized mismatch that
## would fail the §2 staleness echo check). Also fits-and-centers the view
## instead of the old zoom=1/offset=ZERO reset (Jeroen's second finding: an
## n=32 composite is 512px native, a postage stamp unfitted in a real
## viewport).
## server's normalize_window_center() so the client's echo comparison never
## mismatches. Also fits-and-centers the view instead of resetting to
## zoom=1/offset=ZERO.
func enter(
body: Dictionary,
system: Dictionary,
@@ -292,25 +289,28 @@ func enter(
## T-1153: enter the ladder at its TOP REST STATE — the canonical orbital
## frame (Jeroen's HARD condition: "the whole body fitted to the canvas,
## centered at the body's canonical origin"). This is the new "regional" nav
## entry point (T-1152 client half — supersedes AtlasViewer's heightmap
## texture as the sole entry): the player lands on a fully-derived Region-rung
## view of the whole body, then wheel-zoom descends CONTINUOUSLY from there —
## no separate planetary screen, no click-through required to reach the
## windowed view at all (though enter() above stays wired for a
## click-to-descend shortcut, per Jeroen's ruling).
## centered at the body's canonical origin"). This is the "regional" nav
## entry point (T-1152 client half): the player lands on a fully-derived
## Region-rung view of the whole body, then wheel-zoom descends CONTINUOUSLY
## from there — no separate planetary screen, no click-through required
## (though enter() below stays wired for a click-to-descend shortcut, per
## Jeroen's ruling).
##
## Canonical origin = district (0,0) — "district (0,0) sits at lon 0 / the
## equator" (AtlasDescendGeometry's own doc, mirroring
## district_profile.rs). Canonical extent = the WHOLE equatorial
## circumference in districts (district_extent().cols), i.e. one full
## circumnavigation — the same quantity is_fully_zoomed_out()/the
## full-zoom-out reset (see _maybe_reset_to_canonical_frame()) test against,
## so entry and reset always agree on what "the top" means. No-radius bodies
## (tiny test bodies) fall back to the District-rung default window — there
## is no planetary circumference concept to derive a Region-rung n from (same
## fallback disposition AtlasDescendGeometry's own no-radius branches use
## throughout).
## equator" (AtlasDescendGeometry's own doc). Canonical extent = the WHOLE
## equatorial circumference in districts, the same quantity
## is_fully_zoomed_out()/_maybe_reset_to_canonical_frame() test against, so
## entry and reset always agree on what "the top" means. No-radius bodies
## fall back to the District-rung default window (no circumference concept).
##
## **Live round 3 (Jeroen's ruling, design doc §4): the rest state must
## TILE.** A single wire-capped Region window covers at most
## `AtlasWindowGeometry.MAX_COVERAGE_M["Region"]` (13,107,200 m) — a THIRD of
## Lendel's ~39,197,023 m circumference (shot 01's own header: "13107.2 x
## 13107.2 km"). Once `compute_tile_grid()` returns MORE than one tile,
## entry goes through `_enter_tile_mode()` instead of `_enter_at_rung()`; a
## body whose circumference fits one Region window's ceiling still gets
## exactly one "tile" (the degenerate case) and stays single-window.
func enter_orbital(body: Dictionary, system: Dictionary) -> void:
var radius_km: float = float(body.get("body_radius_km", 0.0))
if radius_km <= 0.0:
@@ -320,31 +320,55 @@ func enter_orbital(body: Dictionary, system: Dictionary) -> void:
AtlasWindowRequest.GRANULARITY_V2_DISTRICT
)
return
var tiles: Array = AtlasWindowGeometry.compute_tile_grid(radius_km)
if tiles.size() > 1:
_enter_tile_mode(body, system, radius_km)
return
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
var n: int = int(extent["cols"])
_enter_at_rung(body, system, Vector2i.ZERO, n, AtlasWindowRequest.GRANULARITY_V2_REGION)
## T-1153, live round 3: the TILE-MODE entry path — same reset discipline as
## `_enter_at_rung()` but populates `_tile_set` instead of `_window_request`,
## setting `_tile_mode = true` so drawing/reset/reselect read the mosaic.
## `_held_n` carries the WHOLE body's extent unclamped (each TILE clamps its
## own TILE_N-sized request independently inside AtlasWindowTileSet), so the
## extent math elsewhere needs no tile-specific branch.
func _enter_tile_mode(body: Dictionary, system: Dictionary, radius_km: float) -> void:
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
var n: int = int(extent["cols"])
_body = body
_system = system
_held_center = Vector2i.ZERO
_held_n = n
_held_granularity_v2 = AtlasWindowRequest.GRANULARITY_V2_REGION
_tile_mode = true
_window = null
_user_adjusted = false
_awaiting_first_window = true
_fit_and_center()
_window_request.reset()
_tile_set.enter(_dict_str(_body, "body_id", ""), radius_km)
_refresh_screen_header()
grab_focus()
queue_redraw()
_overlay_node.queue_redraw()
## Shared entry path for enter()/enter_orbital() (T-1153) — `district_center`
## must already be canonicalized by the caller (enter_orbital()'s (0,0) needs
## no canonicalization; enter()'s does its own before calling in). Resets
## every piece of held/request state for a fresh descent, exactly as the
## pre-T-1153 enter() always did, plus the new _held_granularity_v2 tracking.
## must already be canonicalized by the caller. Resets every piece of
## held/request state for a fresh descent, plus _held_granularity_v2.
##
## **The C1 clamp-mirror lesson, one layer up (live-round finding):** `n`
## MUST be clamped via `_clamp_window_n_mirror_v2()` BEFORE it becomes
## `_held_n` — mirroring exactly what AtlasWindowRequest.request_now()
## already does to ITS OWN `_n` before storing/sending (see that function's
## own doc for the original PR #191 Tyre C1 finding). Storing the RAW `n`
## here (e.g. enter_orbital()'s full district_extent().cols, routinely tens
## of thousands at Region granularity, versus the server's clamped echo of
## at most DISTRICT_WINDOW_MAX_N_REGION=6,400) left `_held_n` permanently
## disagreeing with what the server would ever actually echo — every
## orbital-rung response was silently rejected as stale by
## _on_window_ready()'s `w_n != _held_n` check, hanging the ladder on every
## real-sized body. `_maybe_reselect_rung()`/`_maybe_refloat_window()` both
## read `_held_n` (never re-derive it), so clamping here — the ONE write
## site — fixes every downstream caller too, not just entry.
## **C1 clamp-mirror, one layer up (live-round finding):** `n` MUST be
## clamped via `_clamp_window_n_mirror_v2()` BEFORE it becomes `_held_n` —
## mirroring what AtlasWindowRequest.request_now() already does to ITS OWN
## `_n` (PR #191 Tyre C1). Storing RAW `n` (e.g. enter_orbital()'s full
## district_extent().cols, tens of thousands at Region, vs. the server's
## clamped echo of at most 6,400) left `_held_n` permanently disagreeing
## with the server's echo — every orbital response silently rejected as
## stale forever. `_maybe_reselect_rung()`/`_maybe_refloat_window()` both
## read `_held_n` unchanged, so clamping here fixes every downstream caller.
func _enter_at_rung(
body: Dictionary,
system: Dictionary,
@@ -358,6 +382,7 @@ func _enter_at_rung(
_held_center = district_center
_held_n = clamped_n
_held_granularity_v2 = granularity_v2
_tile_mode = false # T-1153 live round 3: a single-window entry always leaves tile mode
_window = null
_user_adjusted = false
_awaiting_first_window = true
@@ -391,11 +416,9 @@ func _fit_and_center() -> void:
_apply_transform()
## T-1142: the pole-wall clamp needs the body's rows_half, in whole districts
## — a no-radius body (tiny test body) has no periodicity/pole concept at the
## DistrictPos level (matching canonicalize_district_center()'s own no-radius
## identity disposition), so the wall is a no-op there (rows_half=0, and
## clamp_pan_offset_to_pole_wall() treats <= 0 as "no wall").
## T-1142: needs the body's rows_half, in whole districts — a no-radius body
## has no pole concept (matching canonicalize_district_center()'s own
## no-radius identity), so the wall is a no-op there (rows_half=0).
func _clamp_offset_to_pole_wall(offset: Vector2) -> Vector2:
var radius_km: float = float(_body.get("body_radius_km", 0.0))
if radius_km <= 0.0:
@@ -417,6 +440,19 @@ func get_district_window() -> Variant:
return _window
## T-1153: true while showing the orbital rest state as a multi-window
## mosaic instead of the single held composite — AtlasWindowOverlay reads
## this to pick a draw path.
func is_tile_mode() -> bool:
return _tile_mode
## T-1153: the tile-set orchestrator, for AtlasWindowOverlay's mosaic draw
## path — only meaningful while is_tile_mode() is true.
func get_tile_set() -> Variant:
return _tile_set
## District-cell pixel size at zoom=1.0 — AtlasWindowOverlay reads this
## rather than hardcoding CELL_PIXEL_SIZE itself, so the viewer stays the
## single source of geometry truth (same "viewer owns the transform, overlay
@@ -451,15 +487,11 @@ func _on_atlas_layers_received(response: Dictionary) -> void:
_window_request.on_response(response)
## T-1153: progressive refinement — this is the ONE place a new rung's
## window gets adopted (swapped in), and it deliberately does NOT clear
## `_window` first. The OLD composite (whatever rung it was) stays drawn
## every frame up to and including the one before this call — no blank
## frame, no mode flip (§6 acceptance criterion) — because `_window` is a
## single-slot "the composite currently drawn" reference that only ever gets
## REPLACED, never nulled, once a window has been adopted at least once
## (enter()/_enter_at_rung() nulls it only at a fresh descent, a real
## navigation event, not a rung swap).
## T-1153: progressive refinement — the ONE place a new rung's window gets
## adopted, deliberately WITHOUT clearing `_window` first. The OLD composite
## stays drawn until this call — no blank frame, no mode flip (§6) —
## because `_window` only ever gets REPLACED, never nulled, once adopted
## (enter()/_enter_at_rung() null it only at a fresh descent, not a swap).
func _on_window_ready(window: Dictionary) -> void:
# Only adopt the window if it still matches what THIS viewer is currently
# showing — AtlasWindowRequest already filtered by its own last-asked
@@ -500,6 +532,15 @@ func _on_window_ready(window: Dictionary) -> void:
_overlay_node.queue_redraw()
## T-1153 (design doc §4 "progressive... with visible refinement as tiles
## complete"): a SINGLE tile's window arrived — redraw so the overlay's
## mosaic loop picks it up. No acceptance/staleness logic needed here (each
## tile's OWN AtlasWindowRequest already filtered before this signal fired).
func _on_tile_ready(_index: int) -> void:
queue_redraw()
_overlay_node.queue_redraw()
# =============================================================================
# View transform (mirrors AtlasViewer's own — pan is real; zoom is CURSOR-
# ANCHORED and CONTINUOUS ACROSS RUNGS (T-1153, D-226 T-1143-rulings
@@ -517,14 +558,11 @@ func _apply_transform() -> void:
_overlay_node.queue_redraw()
## Cursor-anchored zoom (D-013 restored for this seam, Jeroen's ruling): the
## CANVAS POINT under the cursor stays fixed on screen across the zoom step —
## zooming toward the cursor, not the view center. Unclamped ACROSS RUNGS
## (only the wide MIN_ZOOM/MAX_ZOOM safety clamp applies to the raw
## multiplier itself — see that constant's own doc); after applying the new
## zoom, checks whether the currently-displayed world extent now calls for a
## different rung (_maybe_reselect_rung()) and whether the view has reached
## the ladder's top rest state (_maybe_reset_to_canonical_frame()).
## Cursor-anchored zoom (D-013 restored for this seam): the CANVAS POINT
## under the cursor stays fixed on screen across the zoom step. Unclamped
## ACROSS RUNGS (only the wide MIN_ZOOM/MAX_ZOOM safety clamp applies — see
## that constant's own doc); after applying, checks whether the extent now
## calls for a different rung or the top rest state.
func _zoom_at(mouse_pos: Vector2, factor: float) -> void:
var new_zoom: float = clampf(_view_zoom * factor, MIN_ZOOM, MAX_ZOOM)
if is_equal_approx(new_zoom, _view_zoom):
@@ -539,59 +577,71 @@ func _zoom_at(mouse_pos: Vector2, factor: float) -> void:
## The world extent (metres) currently displayed across the LARGER viewport
## dimension — the `E` half of the §5 rung-selection rule's `E/C`. A pure
## function of `_view_zoom` (see AtlasWindowGeometry.world_extent_m()'s own
## doc for why the currently-held rung is NOT an input: the composite's
## on-screen footprint is rung-invariant by construction, so sample density
## depends only on zoom). Thin wrapper kept here so callers don't need to
## know the pure function lives on AtlasWindowGeometry (T-1153 — extracted
## there, alongside select_rung(), to keep the §5 math unit-testable without
## a Control in the tree).
## dimension — the `E` half of the §5 rung-selection rule. A pure function
## of `_view_zoom` (see AtlasWindowGeometry.world_extent_m()'s own doc for
## why the held rung is NOT an input). Thin wrapper over that pure function.
func _current_world_extent_m() -> float:
return AtlasWindowGeometry.world_extent_m(CELL_PIXEL_SIZE, _view_zoom, get_rect().size)
## §5 rung-selection rule + progressive refinement (T-1153): after a zoom
## step, recompute the coarsest legal rung for the NOW-displayed world extent
## (_current_world_extent_m() over the viewport's larger dimension). If that
## differs from what's currently HELD on screen, request the new granularity
## centered on the CURRENT screen-center's district position (reusing
## _screen_center_district() — the same screen-to-district math
## _maybe_refloat_window() already established, which is rung-agnostic since
## CELL_PIXEL_SIZE is always district-based regardless of the held rung's
## true cell spacing — see atlas_window_overlay.gd's cell_grid_side_for_window()
## doc for why that's true).
## step, recompute the legal rung for the NOW-displayed world extent. If it
## differs from what's HELD, request the new granularity centered on the
## CURRENT screen-center (_screen_center_district(), the same formula
## _maybe_refloat_window() uses).
##
## Progressive refinement, not block-on-derive: this does NOT touch `_window`
## or `_held_granularity_v2` — the OLD composite keeps drawing every frame
## (border-fade/pending-indicator per R6 shows the request is in flight, see
## _draw_border_fade()) until _on_window_ready() adopts the NEW rung's window
## once it actually arrives (§6 "no mode flip": never a blank frame, never a
## clear-then-redraw).
## **C1 clamp-mirror, a THIRD layer up (live round 3):** `_held_n` MUST be
## re-clamped via `_clamp_window_n_mirror_v2()` for the TARGET rung, not left
## at the PREVIOUS rung's clamp — crossing rungs changes the clamp ceiling
## (Region caps at 6,400; District/Quarter at 64), so a stale Region-sized
## `_held_n` fed into a Quarter request gets server-clamped small while
## `_held_n` stays large — `_on_window_ready()`'s `w_n != _held_n` then
## drops every cross-rung refinement forever. Same bug as _enter_at_rung(),
## recurring at the CROSSING boundary.
##
## Progressive refinement: does NOT touch `_window`/`_held_granularity_v2` —
## the OLD composite keeps drawing until _on_window_ready() adopts the new
## one (§6 "no mode flip": never a blank frame, never clear-then-redraw).
func _maybe_reselect_rung() -> void:
if _held_n <= 0:
return
var world_extent_m: float = _current_world_extent_m()
var canvas_px: float = maxf(get_rect().size.x, get_rect().size.y)
var target_rung: String = AtlasWindowGeometry.select_rung(world_extent_m, canvas_px)
if target_rung == _window_request.get_granularity_v2():
# T-1153, live round 3: tile mode is TOP-of-the-ladder only (coordinator's
# own scoping — "inside-zoom can stay single-window as now"). Staying at
# Region means staying tiled (zooming within a mosaic is a client-side
# scale on the SAME held tiles, like single-window zoom on one
# composite). Crossing OUT of Region falls through to the single-window
# path below, flipping `_tile_mode` off.
var leaving_tile_mode := false
if _tile_mode:
if target_rung == AtlasWindowRequest.GRANULARITY_V2_REGION:
return
_tile_mode = false
leaving_tile_mode = true
# `leaving_tile_mode` FORCES the request through even if
# `_window_request`'s own STALE granularity_v2 (never touched while tiled)
# happens to already equal `target_rung` by coincidence — without this,
# the early-return below would skip the request that's supposed to
# POPULATE `_window` for the first time since tile mode replaced it.
if not leaving_tile_mode and target_rung == _window_request.get_granularity_v2():
return # already requesting (or holding) the rung this extent calls for
var new_center: Vector2i = _screen_center_district()
var clamped_n: int = AtlasWindowRequest._clamp_window_n_mirror_v2(_held_n, target_rung)
_held_center = new_center
_held_n = clamped_n
_window_request.request_debounced(
_dict_str(_body, "body_id", ""), new_center, _held_n, target_rung
_dict_str(_body, "body_id", ""), new_center, clamped_n, target_rung
)
## The DistrictPos the current screen center maps to, in RAW absolute
## district space (matching _maybe_refloat_window()'s own convention — only
## the caller canonicalizes the final value it actually stores/sends). Thin
## wrapper over AtlasWindowGeometry.screen_center_to_district() (T-1153 —
## extracted alongside the rung-selection math for the same testability
## reason) so both the pan-edge refetch and the rung-reselect refetch share
## ONE screen-to-district formula rather than two copies that could drift
## (the exact lesson _maybe_refloat_window()'s own doc already establishes
## for the pan case).
## district space (the caller canonicalizes the final stored/sent value).
## Thin wrapper over AtlasWindowGeometry.screen_center_to_district() so both
## the pan-edge refetch and the rung-reselect refetch share ONE formula.
func _screen_center_district() -> Vector2i:
var raw: Vector2i = AtlasWindowGeometry.screen_center_to_district(
size, _view_offset, _view_zoom, CELL_PIXEL_SIZE, _held_center, _held_n
@@ -600,19 +650,13 @@ func _screen_center_district() -> Vector2i:
return AtlasDescendGeometry.canonicalize_district_center(raw, radius_km)
## Jeroen's HARD condition (D-226 T-1143-rulings amendment): "a full
## zoom-out resets to the original canonical planetary frame and location" —
## the ladder's TOP REST STATE, never a drifted pan/zoom-out state. Fires
## when the CURRENTLY DISPLAYED world extent (at _held_granularity_v2, the
## rung actually on screen — deliberately NOT the in-flight request's rung,
## so this can't fire prematurely off a request that hasn't landed yet)
## covers the whole body (AtlasWindowGeometry.is_fully_zoomed_out()) AND the
## player isn't ALREADY sitting at the canonical frame (center == (0,0) —
## re-entering the SAME enter_orbital() state on every zoom tick past the
## threshold would fight a player trying to zoom back IN from the top, since
## every zoom-out tick would keep re-snapping to the identical framing).
## Returns true if it fired (the caller should skip _maybe_reselect_rung() —
## the reset already re-requested at the canonical Region-rung window).
## Jeroen's HARD condition: "a full zoom-out resets to the original
## canonical planetary frame and location" — the ladder's TOP REST STATE,
## never a drifted pan/zoom-out state. Fires when the CURRENTLY DISPLAYED
## extent (at _held_granularity_v2, deliberately NOT the in-flight request's
## rung) covers the whole body AND the player isn't ALREADY at the canonical
## frame (re-snapping every tick would fight a zoom-in-from-the-top
## gesture). Returns true if it fired (caller skips _maybe_reselect_rung()).
func _maybe_reset_to_canonical_frame() -> bool:
var radius_km: float = float(_body.get("body_radius_km", 0.0))
if radius_km <= 0.0:
@@ -650,29 +694,26 @@ func set_view(zoom: float, offset: Vector2) -> void:
# =============================================================================
## After a pan delta (T-1145: WASD/edge-scroll, called from _process()'s pan
## tick every frame the player is actively panning), check whether the
## After a pan delta (T-1145: WASD/edge-scroll), check whether the
## screen-center now maps to a DistrictPos outside the held window's extent
## — if so, float a NEW window centered on that point (§5 "windows float on
## the pan center... not grid-snapped") via the debounced request path.
## — if so, float a NEW window centered on that point via the debounced path.
##
## T-1142 (item 6a): the edge-crossing decision below is computed in RAW
## absolute district space (un-wrapped, un-clamped) — that is the correct
## space for "has the pan carried the view past the held window's edge",
## since the held window's own local bounds are relative to _held_center as
## it was BEFORE this pan. Only the FINAL new_center that becomes the next
## _held_center / the next request is canonicalized (wrap column, clamp
## row) — matching the server's own normalize_window_center() and keeping
## the client's echo-comparison and cache key on the same canonical form the
## server uses (see canonicalize_district_center()'s doc for why this must
## match bit-for-bit). A pan that straddles the antimeridian therefore still
## floats correctly: the pre-canonicalization abs_col can be e.g. -3 or
## district_cols+5, the edge-crossing math treats that as a normal delta from
## the old center, and only the resulting new_center gets wrapped into range
## before it's requested/cached.
## T-1142 (item 6a): the edge-crossing decision is computed in RAW absolute
## district space (un-wrapped, un-clamped) — the held window's own local
## bounds are relative to _held_center as it was BEFORE this pan. Only the
## FINAL new_center is canonicalized (wrap column, clamp row), matching the
## server's normalize_window_center() so the echo comparison/cache key stay
## on the same canonical form. A pan straddling the antimeridian still
## floats correctly: the pre-canonicalization abs_col can go negative or
## past cols, and only the resulting new_center gets wrapped before use.
func _maybe_refloat_window() -> void:
if _held_n <= 0:
return
if _tile_mode:
# T-1153: the tile set already covers the WHOLE body — no "edge" to
# cross while tiled. _apply_pan_delta() still moves _view_offset;
# this only skips the single-window re-float below.
return
var raw_new_center: Vector2i = AtlasWindowGeometry.screen_center_to_district(
size, _view_offset, _view_zoom, CELL_PIXEL_SIZE, _held_center, _held_n
)
@@ -716,6 +757,11 @@ func _maybe_refloat_window() -> void:
func _draw() -> void:
draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG)
if _tile_mode:
# T-1153: single-window border-fade/pending-wash don't apply to a
# mosaic — AtlasWindowOverlay's tile draw only paints arrived tiles;
# an unarrived one is an honest gap over COLOR_BG, no separate fade.
return
if _window == null:
# §5 "what renders during the wait": a border-fade to the underlying
# whole-body context rather than black/a spinner. This viewer has no
@@ -760,39 +806,26 @@ func _build_screen_header() -> void:
_screen_header.apply_implant_theme(_implant_theme)
## D-169/D-170 implant chrome (§5): location label (body name + coordinate,
## T-1142 — see _location_label()) + extent-in-real-units subtitle, e.g.
## "4.1 x 4.1 km . 2.0 km/cell". T-1153: the extent (`n` districts) is
## rung-INVARIANT (n is always district extent — see
## AtlasWindowOverlay.cell_grid_side_for_window()'s doc), but the km/cell
## reading must reflect the HELD rung's actual spacing (2.048 km at District,
## 0.512 km at Quarter, 204.8 km at Region) — this is the "continuous
## metres-per-pixel/extent readout" the 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, no named
## mode chrome does.
## D-169/D-170 implant chrome (§5) — title/subtitle text built by the pure
## AtlasWindowGeometry.screen_header_content() (T-1153: moved there for
## file-length; the "continuous metres-per-pixel readout, never a discrete
## mode label" rationale lives on that function's own doc now).
func _refresh_screen_header() -> void:
if _screen_header == null:
return
var location_label: String = _location_label()
var extent_km: float = float(_held_n) * DISTRICT_M / 1000.0
var spacing_km: float = AtlasWindowGeometry.spacing_for_rung(_held_granularity_v2) / 1000.0
var extent_line: String = "%.1f x %.1f km · %.3f km/cell" % [extent_km, extent_km, spacing_km]
var title: String = "REGIONAL — %s" % location_label.to_upper()
_screen_header.set_content(title, extent_line)
var content: Dictionary = AtlasWindowGeometry.screen_header_content(
_dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—")),
_held_center, _held_n, _held_granularity_v2, DISTRICT_M
)
_screen_header.set_content(content["title"], content["subtitle"])
## Body name + coordinate label (T-1142: pulls the CHEAP half of T-1141
## forward — the body's proper name was already sitting unused on _body,
## passed through the whole descend chain since T-1138, but this header never
## read it, showing bare "district (col, row)" with no indication of WHICH
## body the player is looking at. T-1141 keeps only the harder half: nearest-
## settlement proximity join (the window carries no settlement data of its
## own — that lives on the planetary gen_l3_settlements overlay, a different
## screen/dataset — a real follow-up, not a silently-guessed one).
## Thin wrapper over AtlasWindowGeometry.location_label() (T-1153: moved
## there for file-length) — kept as a method since it's directly tested.
func _location_label() -> String:
var body_name: String = _dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—"))
return "%s — (%d, %d)" % [body_name, _held_center.x, _held_center.y]
return AtlasWindowGeometry.location_label(
_dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—")), _held_center
)
# =============================================================================
@@ -811,16 +844,10 @@ func _is_over_ui(_pos: Vector2) -> bool:
## T-1145 item 2: LMB-drag panning is GONE (Jeroen's ruling — drag broke click
## semantics with map objects; clicks are reserved for future map objects,
## e.g. settlements). What remains: wheel zoom (unchanged) and tracking the
## local mouse position for edge-scroll (_process() reads _last_mouse_pos —
## it has no InputEvent of its own to read a live position from). WASD/arrow
## panning does NOT go through _gui_input at all — it is a HELD-key,
## continuous, frame-rate-independent pan polled every frame in _process()
## via Input.is_action_pressed()-equivalent raw key checks (Input.is_key_pressed(),
## since WASD has no project-level Input Map action of its own in this
## screen's remit — see _process()'s own doc for why raw physical-keycode
## polling is deliberate here, not a new InputMap action).
## semantics with map objects). What remains: wheel zoom and tracking the
## local mouse position for edge-scroll (_process() has no InputEvent of its
## own). WASD/arrow panning does NOT go through _gui_input — it's a
## HELD-key, frame-rate-independent pan polled every frame in _process().
func _gui_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.is_echo():
_handle_key(event as InputEventKey)
@@ -850,18 +877,11 @@ func _handle_key(event: InputEventKey) -> void:
## T-1145 item 2: continuous WASD/arrow-key pan + edge-scroll, both applied
## here (not _gui_input) because both are HELD-state effects (keys held down,
## cursor lingering near an edge), not discrete input events — _process()
## polls held state every frame and hands the resulting direction + this
## frame's delta to _apply_pan_delta() (split out for testability — a gdUnit
## test drives _apply_pan_delta(direction, delta) directly with a
## deterministic direction/delta instead of needing to fake Godot's global
## Input singleton reporting a key held, which is what testing THIS
## function's own Input.is_key_pressed() polling would require). Skips
## entirely while this Control is hidden (the screen is not the active
## nav-stack entry) — no wasted per-frame work for an invisible viewer, and
## no phantom panning if some other code path leaves this node in the tree
## but not shown.
## here (not _gui_input) since both are HELD-state effects, not discrete
## events — polls held state every frame and hands the direction + delta to
## _apply_pan_delta() (split out for testability — a gdUnit test drives it
## directly rather than faking Godot's global Input singleton). Skips while
## hidden (screen not the active nav-stack entry).
func _process(delta: float) -> void:
if not visible:
return
@@ -873,15 +893,11 @@ func _process(delta: float) -> void:
_apply_pan_delta(direction, delta)
## The actual pan-tick state mutation, given an ALREADY-DECIDED (but not yet
## normalized) direction and this frame's delta — frame-rate independent
## (motion scales by `delta`, so the same speed at 30fps or 144fps), zoom-
## scaled (PAN_SPEED_CANVAS_PX_S * _view_zoom — see that constant's own doc
## for why), and pole-wall clamped (T-1142, unchanged mechanism, just fed by
## a different input source now). Sets _user_adjusted (T-1145: "WASD/edge/
## zoom all set _user_adjusted") and triggers the SAME pan-edge refetch check
## (§4) drag used to. Split from _process() specifically so a test can call
## this directly with a synthetic direction/delta — see _process()'s own doc.
## The actual pan-tick state mutation, given an ALREADY-DECIDED direction and
## this frame's delta — frame-rate independent, zoom-scaled
## (PAN_SPEED_CANVAS_PX_S * _view_zoom), pole-wall clamped (T-1142). Sets
## _user_adjusted and triggers the pan-edge refetch (§4). Split from
## _process() so a test can call it directly with a synthetic direction/delta.
func _apply_pan_delta(direction: Vector2, delta: float) -> void:
var normalized: Vector2 = direction.normalized() # diagonal isn't faster than a single axis
_user_adjusted = true