fix(ui): T-1142 client — descent bounds gate, fit-and-center, pole wall, east-west wrap, body-name header

Bounds gate: AtlasDescendGeometry.is_on_texture() — ONE helper feeding
both the reticle guard and the click fall-through (the T-1140 lesson:
the visible affordance always matches the click); half-open
[0,tex_w)x[0,tex_h) boundary pinned at the exact edge. Letterbox
clicks no longer show a reticle or descend.

Fit-and-center: pure fit_window_view() (new atlas_window_geometry.gd)
wired into enter(), the FIRST window arrival, and NOTIFICATION_RESIZED
— gated by a _user_adjusted flag so the fit never fights manual
zoom/pan (flag clears only on a fresh enter). Found-own-bug: RESIZED
can fire mid-_ready() before _canvas exists — null-guarded like the
sibling panels.

Pole wall (Jeroen's ruling): clamp_pan_offset_to_pole_wall() clamps
the WINDOW EDGE, not the center, in screen space from the fitted
transform — Y only; wired into the drag handler and every fit (a
fresh fit can itself need the wall on a tiny body — the
window-taller-than-planet case is handled and tested). Three numeric
hand-traces preceded the code; a first-draft test using GJ380c's huge
radius silently never exercised the clamp — replaced with a synthetic
small radius.

East-west wrap (Jeroen's ruling): canonicalize_district_center() —
posmod column wrap (verified against a live Godot process to match
Rust rem_euclid bit-for-bit), clamped row; district_extent() shares
the exact formula (incl. .max(1)) with the server's
normalize_window_center so echoes and cache keys agree on canonical
form. Canonicalization applies only to the FINAL refetch center — the
edge-crossing decision stays in absolute district space (first-pass
math error caught by hand-trace). Seam-adjacent cache-key sharing
tested. Pan offset itself has no x wall — circumnavigation is
seamless.

Header: body proper_name/body_id ahead of the coordinates (the cheap
half of T-1141, noted in code). Drag-pan verified through the REAL
DistrictScreen-to-viewer chain and pinned by test (no fix needed).

140 tests across three suites, 0 failures; 94 sibling tests no
ripple; gdlint clean (atlas_viewer.gd at the 1000-line cap a second
round — structural extraction flagged for maintenance).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 14:53:32 +02:00
co-authored by Claude Fable 5
parent 4a65bb210d
commit 831625019a
7 changed files with 966 additions and 39 deletions
@@ -75,6 +75,27 @@ static func reticle_label(center: Vector2) -> Dictionary:
}
## Whole-body district extent (columns spanning the full equatorial
## circumference; the half-meridian row range, i.e. equator to either pole)
## for a body of `body_radius_km`. Shared by district_pos_at(),
## canonicalize_district_center(), and AtlasWindowViewer's pole-wall pan
## clamp — ONE formula, matching server/src/atlas/layer_proxy.rs's
## normalize_window_center() EXACTLY (T-1142 canonicalization, dudley's
## in-flight server counterpart): `districts_per_circumference =
## round(circumference_m / DISTRICT_M).max(1)`, `half_meridian_districts =
## round(meridian_m / DISTRICT_M / 2.0)`. The `.max(1)` floor on cols matters
## for canonicalization's rem_euclid (a zero modulus panics/undefined-behaves
## on the server; GDScript's `%` on 0 is likewise not safe to rely on) even
## though no real systems.db body is small enough to hit it.
static func district_extent(body_radius_km: float) -> Dictionary:
var circumference_m: float = TAU * body_radius_km * 1000.0
var meridian_m: float = PI * body_radius_km * 1000.0
return {
"cols": maxf(roundf(circumference_m / DISTRICT_M), 1.0),
"rows_half": roundf((meridian_m / DISTRICT_M) * 0.5),
}
## Inverse of the server's derive_district() pixel mapping
## (server/src/atlas/district_profile.rs) — a `true_district_of_pixel`-style
## function, per the amendment's §5 carry-over wording. The server's forward
@@ -111,11 +132,8 @@ static func district_pos_at(
return Vector2i.ZERO
if body_radius_km <= 0.0:
return Vector2i(roundi(canvas_pt.x), roundi(canvas_pt.y))
var circumference_m: float = TAU * body_radius_km * 1000.0
var meridian_m: float = PI * body_radius_km * 1000.0
var district_cols: float = roundf(circumference_m / DISTRICT_M)
var district_rows_half: float = roundf((meridian_m / DISTRICT_M) * 0.5)
var col: int = roundi((canvas_pt.x / tex_w) * district_cols)
var extent: Dictionary = district_extent(body_radius_km)
var col: int = roundi((canvas_pt.x / tex_w) * float(extent["cols"]))
# tex_h - 1.0, matching the forward map's ta.h.saturating_sub(1) — NOT a
# bare tex_h (see the docstring above; this was a live bug, T-1138 PR #187
# review, Hoshe: every off-equator click descended into the wrong district).
@@ -123,5 +141,61 @@ static func district_pos_at(
# `if ta_h > 1 { ... } else { 0.0 }`) guards the same division — a
# degenerate 1px-tall texture would otherwise divide by zero.
var lat_frac: float = ((canvas_pt.y / (tex_h - 1.0)) - 0.5) if tex_h > 1.0 else 0.0
var row: int = roundi(lat_frac * district_rows_half * 2.0)
var row: int = roundi(lat_frac * float(extent["rows_half"]) * 2.0)
return Vector2i(col, row)
## T-1142 addendum (Jeroen — pole-wall/east-west-wrap ruling): canonicalize a
## district-window center to the SAME range the server's
## normalize_window_center() (server/src/atlas/layer_proxy.rs) produces —
## column WRAPS (longitude is periodic; rem_euclid into [0, cols)), row
## CLAMPS (latitude terminates at the poles; clamp into [-rows_half,
## rows_half]). Load-bearing that this matches the server bit-for-bit: the
## server echoes back the NORMALIZED center in DistrictWindowLayer.center, so
## a client that requests a raw (un-normalized) center but compares against
## its own raw value in the §2 staleness guard would reject every legitimate
## response for an out-of-range request as "stale". Canonicalizing HERE,
## before the request is even sent, means the client's held `_center` already
## equals what the server will echo — no drift between the two sides'
## "canonical" concepts, and the cache key (built from the same canonicalized
## Vector2i) naturally de-dupes a full-circumnavigation pan back to a
## previously-fetched column.
##
## No-radius bodies (tiny test bodies, BodyParams' own doc) are identity —
## same fallback disposition as normalize_window_center()'s own no-radius
## branch (the forward map's no-radius path has no periodicity concept).
static func canonicalize_district_center(center: Vector2i, body_radius_km: float) -> Vector2i:
if body_radius_km <= 0.0:
return center
var extent: Dictionary = district_extent(body_radius_km)
var cols: int = int(extent["cols"])
var rows_half: int = int(extent["rows_half"])
# GDScript's % on negative operands follows sign-of-dividend (like Rust's
# %, NOT rem_euclid) — posmod() is Godot's rem_euclid equivalent, exactly
# what the server's DistrictPos.rem_euclid(districts_per_circumference) does.
var wrapped_col: int = posmod(center.x, cols)
var clamped_row: int = clampi(center.y, -rows_half, rows_half)
return Vector2i(wrapped_col, clamped_row)
## T-1142 (Jeroen's first hands-on click, PR #187 follow-up): true unless the
## canvas point lies ON the heightmap texture, [0, tex_w) x [0, tex_h). The
## fixed planetary view (T-1138) can letterbox a non-2:1-aspect viewport
## around the 2:1 heightmap — AtlasViewer's mouse-motion/click handlers see
## every screen point in the FULL Control rect, including the letterbox dead
## zone beside/above/below the actual map, and screen_to_canvas() has no
## opinion about whether the resulting canvas point is still ON the texture
## (it is a pure affine inverse — it happily returns x=1400 for a click at
## screen-x 1900 on a 1024px-wide fitted texture). Left unchecked, a letterbox
## click both (a) shows the descend reticle (a promise) and (b) derives a
## DistrictPos from an out-of-range canvas point — Jeroen's exact repro
## (clicked the letterbox, landed at column 12276 on a body whose max valid
## column is ~11236, and the server's clamped-sampling derive at that
## beyond-the-planet position produced uniform green).
##
## ONE named helper, used by BOTH the reticle-show guard and the descend
## click fall-through (never two independent bounds checks that could drift
## — the same "one truth" lesson T-1140's hover/reticle mismatch already
## taught: the visible affordance must always match what the click does).
static func is_on_texture(canvas_pt: Vector2, tex_w: float, tex_h: float) -> bool:
return canvas_pt.x >= 0.0 and canvas_pt.x < tex_w and canvas_pt.y >= 0.0 and canvas_pt.y < tex_h
+15 -15
View File
@@ -3,20 +3,13 @@ extends Control
## Atlas regional viewer — heightmap PNG with marker overlay (#835, D-191).
##
## Lives as a child of RegionalScreen, shown when the atlas nav stack is at
## "regional". Receives body/system context via show_body(). Emits back_pressed,
## economics_link_requested, and district_descend_requested so RegionalScreen
## can route them.
##
## Lives as a child of RegionalScreen (via show_body()). Emits back_pressed,
## economics_link_requested, district_descend_requested for RegionalScreen to route.
## Design notes:
## - Heightmap texture is drawn on a Node2D _canvas child. MarkerOverlay is a
## child of _canvas so markers auto-follow the same transform.
## - markers.json schema (D-191 §8): cities, roads, railroads, pois, plus rivers,
## oceans, mountain_ranges with `center: [row, col]` and optional names.
## - Empty markers case (server #832/#833 not yet shipped): bare heightmap renders
## fine, no sidebar opens, overlays draw nothing.
## - Overlays (#836) plug into _overlay_visibility dict and _draw_overlays().
## Navigation (view FIXED, T-1138): hover reticle (hidden/city) · click descend/city · esc back
## - Heightmap on a Node2D _canvas child; MarkerOverlay is a child of _canvas too.
## - markers.json schema (D-191 §8): cities, roads, rail, pois, rivers, oceans, ranges.
## - Empty markers: bare heightmap, no sidebar. Overlays (#836): _overlay_visibility dict.
## Navigation (FIXED, T-1142 bounds-gated): hover reticle · click descend/city · esc back
signal back_pressed
signal economics_link_requested(system_id: String)
@@ -649,7 +642,12 @@ func _draw() -> void:
func _should_draw_descend_reticle() -> bool: # hidden over a city — _try_click_city wins the click
return _hover_active and _heightmap_texture != null and _hovered_city.is_empty()
var base_ok: bool = _hover_active and _heightmap_texture != null and _hovered_city.is_empty()
return base_ok and _is_screen_pos_on_texture(_hover_screen_pos)
func _is_screen_pos_on_texture(screen_pos: Vector2) -> bool: # T-1142: ONE bounds-gate helper
return AtlasDescendGeometry.is_on_texture(screen_to_canvas(screen_pos), _tex_w, _tex_h)
func _draw_descend_reticle() -> void: # draw calls only — geometry in atlas_descend_geometry.gd
@@ -768,7 +766,9 @@ func _try_click_city(screen_pos: Vector2) -> bool: # T-1138: a city's hit-radiu
return true
func _descend_at(screen_pos: Vector2) -> void: # T-1138: every point maps to a DistrictPos
func _descend_at(screen_pos: Vector2) -> void: # T-1142: inert off-texture (letterbox)
if not _is_screen_pos_on_texture(screen_pos):
return
district_descend_requested.emit(_district_pos_at(screen_pos))
@@ -0,0 +1,104 @@
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")
## Fit-and-center: given the viewport size and the window's side length in
## districts, compute the zoom/offset that fills ~90% of the smaller viewport
## dimension and centers the composite. Mirrors AtlasViewer's own
## _fit_to_view() shape (fit-to-smaller-dimension, 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.
##
## zoom = clampf(0.9 * min(viewport.x, viewport.y) / (n * cell_px), MIN_ZOOM, MAX_ZOOM)
## — the 0.9 factor leaves a visible margin around the composite (same
## "don't touch the edges" instinct as AtlasViewer's own 0.92 fit factor,
## slightly more generous here since the window composite has no header/
## overlay-bar chrome competing for the same rect the way the planetary view
## does). offset centers the (n * cell_px * zoom)-sized composite in the
## viewport.
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(
0.9 * minf(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))
@@ -55,6 +55,10 @@ const COLOR_BORDER_FADE: Color = Color(0.20, 0.24, 0.30, 0.55)
const DISTRICT_M: float = 2048.0
const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd")
# T-1142: fit-and-center + pole-wall pan-clamp math (canonicalization lives on
# 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")
# ── Overlay definitions (T-1138 — reuses atlas_overlay_bar.gd/
# atlas_legend_panel.gd's existing duck-typed viewer interface: both call
@@ -99,6 +103,17 @@ var _view_zoom: float = 1.0
var _dragging: bool = false
var _drag_start_mouse: Vector2
var _drag_start_offset: Vector2
# T-1142: true once the user has manually dragged/zoomed since the last
# enter()/fit — auto-fit (enter, first window arrival, resize) only re-fits
# BEFORE this flips, so it never fights a player mid-interaction. Reset to
# false on every enter() (a fresh descent always starts fitted).
var _user_adjusted: bool = false
# T-1142: true from enter() until the FIRST _on_window_ready() fires (the
# actual composite's arrival re-fits once, in case the entry-time fit used a
# not-yet-final viewport size) — false after that first arrival, so LATER
# pan-triggered window arrivals never re-fit on their own (only entry/first-
# arrival/resize do, per the ticket's three named events).
var _awaiting_first_window: bool = true
# ── Overlay visibility ─────────────────────────────────────────────────────
var _overlay_visibility: Dictionary = {}
@@ -155,6 +170,16 @@ func _exit_tree() -> void:
## equivalent Vector2i, from the planetary click-through's derived position —
## §5's "pan center read as click point"). n defaults to the client's
## interactive default (32), half the server's hard cap.
##
## 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).
func enter(
body: Dictionary,
system: Dictionary,
@@ -163,20 +188,55 @@ func enter(
) -> void:
_body = body
_system = system
_held_center = district_center
var radius_km: float = float(_body.get("body_radius_km", 0.0))
_held_center = AtlasDescendGeometry.canonicalize_district_center(district_center, radius_km)
_held_n = n
_window = null
_view_zoom = 1.0
_view_offset = Vector2.ZERO
_apply_transform()
_user_adjusted = false
_awaiting_first_window = true
_fit_and_center()
_window_request.reset()
_window_request.request_now(_dict_str(_body, "body_id", ""), district_center, n)
_window_request.request_now(_dict_str(_body, "body_id", ""), _held_center, n)
_refresh_screen_header()
grab_focus()
queue_redraw()
_overlay_node.queue_redraw()
## T-1142: fit-and-center — applies AtlasWindowGeometry.fit_window_view()'s
## zoom/offset, then re-clamps the offset to the pole wall (a freshly-fitted
## view can still need the wall on a tiny body whose row span is shorter than
## the window itself — see atlas_window_geometry.gd's clamp function doc).
## Called from enter(), the FIRST _on_window_ready() after entry, and
## NOTIFICATION_RESIZED — never mid-interaction (guarded by _user_adjusted at
## each call site, not here, since the three callers gate slightly differently).
func _fit_and_center() -> void:
var viewport: Vector2 = get_rect().size
if viewport == Vector2.ZERO:
viewport = Vector2(1280.0, 720.0)
var fit: Dictionary = AtlasWindowGeometry.fit_window_view(
viewport, _held_n, CELL_PIXEL_SIZE, MIN_ZOOM, MAX_ZOOM
)
_view_zoom = fit["zoom"]
_view_offset = _clamp_offset_to_pole_wall(fit["offset"])
_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").
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:
return offset
var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km)
return AtlasWindowGeometry.clamp_pan_offset_to_pole_wall(
offset, get_rect().size, _held_center, _held_n, int(extent["rows_half"]), CELL_PIXEL_SIZE, _view_zoom
)
func leave() -> void:
pass
@@ -234,6 +294,14 @@ func _on_window_ready(window: Dictionary) -> void:
if w_center != _held_center or w_n != _held_n:
return
_window = window
# T-1142: re-fit on the FIRST composite arrival only (the entry-time fit
# may have used a viewport size the layout hadn't settled into yet — this
# corrects it once) — never on a later pan-triggered arrival, and never
# once the user has manually zoomed/panned (same _user_adjusted guard
# enter()/NOTIFICATION_RESIZED use).
if _awaiting_first_window and not _user_adjusted:
_fit_and_center()
_awaiting_first_window = false
_refresh_screen_header()
queue_redraw()
_overlay_node.queue_redraw()
@@ -290,6 +358,21 @@ func set_view(zoom: float, offset: Vector2) -> void:
## 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.
##
## 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.
func _maybe_refloat_window() -> void:
if _held_n <= 0:
return
@@ -301,8 +384,8 @@ func _maybe_refloat_window() -> void:
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
var new_center := Vector2i(roundi(abs_col), roundi(abs_row))
if new_center == _held_center:
var raw_new_center := Vector2i(roundi(abs_col), roundi(abs_row))
if raw_new_center == _held_center:
return
# Edge-crossing check: only re-request if the screen-center point has
# actually left the CURRENTLY HELD window's extent — a pan that stays
@@ -320,6 +403,10 @@ func _maybe_refloat_window() -> void:
)
if inside:
return
var radius_km: float = float(_body.get("body_radius_km", 0.0))
var new_center: Vector2i = AtlasDescendGeometry.canonicalize_district_center(
raw_new_center, radius_km
)
_held_center = new_center
_window_request.request_debounced(_dict_str(_body, "body_id", ""), new_center, _held_n)
@@ -360,9 +447,8 @@ func _build_screen_header() -> void:
_screen_header.apply_implant_theme(_implant_theme)
## D-169/D-170 implant chrome (§5): location label (nearest settlement when
## the window is over/near one, else a coordinate/region label — the window
## is NOT settlement-anchored) + extent-in-real-units subtitle, e.g.
## 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".
func _refresh_screen_header() -> void:
if _screen_header == null:
@@ -376,14 +462,17 @@ func _refresh_screen_header() -> void:
_screen_header.set_content(title, extent_line)
## Coordinate/region label — no settlement join exists at this layer yet
## (the district window carries no settlement data of its own; that lives on
## the planetary gen_l3_settlements overlay, a different screen). This is
## deliberately the coordinate fallback branch always, until a future ticket
## wires a settlement-proximity join — recorded as an open follow-up, not
## silently guessed at.
## 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).
func _location_label() -> String:
return "district (%d, %d)" % [_held_center.x, _held_center.y]
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]
# =============================================================================
@@ -415,8 +504,10 @@ func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed:
_user_adjusted = true
_zoom_at(mb.position, ZOOM_STEP)
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed:
_user_adjusted = true
_zoom_at(mb.position, 1.0 / ZOOM_STEP)
elif mb.button_index == MOUSE_BUTTON_LEFT:
if mb.pressed:
@@ -428,7 +519,12 @@ func _gui_input(event: InputEvent) -> void:
elif event is InputEventMouseMotion:
var mm := event as InputEventMouseMotion
if _dragging:
_view_offset = _drag_start_offset + (mm.position - _drag_start_mouse)
_user_adjusted = true
var dragged_offset: Vector2 = _drag_start_offset + (mm.position - _drag_start_mouse)
# T-1142 pole-wall: clamp Y only (item 5) — the window edge, not
# merely its center, must never cross ±rows_half. X is untouched
# (item 6: east-west circumnavigation is seamless, no wall).
_view_offset = _clamp_offset_to_pole_wall(dragged_offset)
_apply_transform()
_maybe_refloat_window()
@@ -477,6 +573,14 @@ func _notification(what: int) -> void:
_position_overlay_bar()
if _legend_panel:
_legend_panel.reposition()
# T-1142: re-fit on resize too, same _user_adjusted guard as the other
# two auto-fit events (enter, first window arrival) — never fights a
# manually-adjusted view. _canvas guard matches _overlay_bar/
# _legend_panel above: NOTIFICATION_RESIZED can fire mid-_ready()
# (anchor_right/anchor_bottom assignment triggers it) BEFORE _canvas
# is constructed — confirmed the hard way (gdUnit add_child() crash).
if _canvas and not _user_adjusted:
_fit_and_center()
## Safely extract a string field from a dict, falling back when missing or