Merge remote-tracking branch 'origin/main' into atlas-latitude-fix

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
2026-07-25 13:30:16 +02:00
10 changed files with 861 additions and 15 deletions
@@ -6,11 +6,19 @@ extends Node2D
## positions world->screen transformed per-frame via a plain linear map
## (StepCanvasTransport.world_m_to_canvas_local()). This is the layer that
## makes `_zs()`/`_zs_stroke()`/`_zs_ring_radius()` structurally
## unnecessary: because this Node2D is NEVER scaled (no `.scale` write
## anywhere in this file, unlike the retired `_canvas.scale` model), a
## constant like COURSE_WIDTH_PX below already IS the on-screen width with
## no compensating division — "by construction, not by discipline" per
## Stig's round-1 design doc.
## unnecessary: this Node2D itself is NEVER scaled (no `.scale` write
## anywhere in THIS file), so a constant like COURSE_WIDTH_PX below already
## IS the width in ITS OWN local space with no compensating division — "by
## construction, not by discipline" per Stig's round-1 design doc.
##
## T-1192 note: the OWNING `_canvas` Node2D (StepCanvasViewer) carries the
## Global integer-fit multiplier as ITS OWN `.scale` (1.0 for every other
## rung) — this layer, as a child, inherits it like the terrain layer does,
## so a marker drawn here lands on the correct enlarged-canvas position AND
## reads visually bigger in step with the enlarged terrain pixels (the
## correct "2x zoom, 2x dot" map read), not a mismatch. Set once per canvas
## adoption by the owner (never per-frame here), so it stays the single
## sanctioned display-time scale, not a second one to reconcile against.
##
## Data source: the SAME decoded StepCanvasResponse `canvas` Dictionary the
## terrain layer reads (`courses`/`cliffs`/`settlement_id` — sparse
@@ -12,11 +12,19 @@ extends ImplantPanel
## No `class_name` on purpose, matching every other viewer-owned helper in
## this cluster.
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd")
const PANEL_MARGIN: float = 16.0
const LEGEND_PANEL_WIDTH: float = 260.0
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd")
## T-1192 review fix: StepCanvasTransport.LEGEND_COLUMN_PX is now the ONE
## canonical source for this width — this file no longer computes its own
## independently-typed literal sum. A read of the transport constant, not a
## derivation, so the two sides can never drift apart by construction
## (transport is static-only, no scene-tree dependency in this direction —
## legend already preloads it above for AtlasOverlayColors-style helpers).
const RESERVED_COLUMN_PX: float = StepCanvasTransport.LEGEND_COLUMN_PX
const MORPHOLOGY_FAMILY_ROWS: Array = [
{"label": "water", "zones": [0, 1]},
@@ -36,6 +36,18 @@ extends Node2D
## already present in the texture — this is presentation resampling of a
## closed input set, not invention of a new one, matching D-255(e)'s own
## "texture-to-viewport resize" exemption.
##
## **T-1192 outer fit scale:** the owning `_canvas` Node2D
## (StepCanvasViewer._recompute_canvas_transform()) may additionally carry
## its OWN `.scale` — for the Global rung, `StepCanvasTransport.
## fit_scale_from_ratio()`'s per-body integer pixels-per-gridunit fit
## (D-255 amendment 2026-07-25: rung-0's display ratio is viewport-fitted
## per body to an INTEGER px/gridunit ratio, not a fixed 5x5); always 1.0
## for every fixed rung — applied on top of this node's own texel-exact
## `_footprint_px` draw. That is a SECOND texture-to-viewport resize, same
## D-255(e) exemption, kept as a parent-transform multiply rather than a
## second internal scale field so this node's own footprint math never has
## to know it exists.
const StepCanvasColorize := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_colorize.gd")
const StepCanvasTransport := preload("res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd")
@@ -85,6 +85,16 @@ const DISPLAY_RATIO_BY_RUNG: Dictionary = {
## server-side, per step_canvas_protocol.gd's own doc).
const FIXED_CANVAS_MAX_AXIS: int = 3_840
## T-1192: the on-screen column width reserved for the Atlas legend panel
## (panel width plus a margin on each side). THIS is the canonical value —
## step_canvas_legend.gd's own RESERVED_COLUMN_PX is a direct read of this
## constant (review fix: was previously an independently-typed literal sum
## on the legend side, which could silently drift from this one), so the
## Global integer-fit computation and the legend's own sizing can never
## disagree by construction — "legend beside, never over" only holds if
## both sides agree on the SAME reserved width.
const LEGEND_COLUMN_PX: float = 260.0 + 16.0 * 2.0
## The rung name at ladder index `i`, clamped to the legal [0, 5] range —
## the one place RUNG_LADDER is indexed into, so a caller passing an
@@ -188,6 +198,59 @@ static func canvas_footprint_px(rung: String, extent_cells: Vector2i) -> Vector2
return Vector2(extent_cells) * ratio
## Shared letterbox/centering mechanism (T-1189 + T-1192, one geometry both
## the Region-rung body-cap remainder AND the Global-rung raw-scale-fill
## defect resolve through — the tickets interact at exactly this seam,
## Jeroen's own instruction: "build ONE letterbox/centering mechanism both
## use, not two"). Given a canvas footprint (px, already at whatever scale
## the caller wants drawn) and the viewport to center it in, returns the
## `Vector2` top-left offset that centers it — the remainder splits evenly
## on both sides (a true letterbox, not edge-anchored). A canvas at or larger
## than the viewport on an axis gets a zero/negative offset on that axis (no
## letterbox needed there — it already fills or overflows, matching ordinary
## pan-and-crop behavior on that axis rather than shrinking the canvas).
## FLOORED to a whole pixel on each axis (D-255 texel-exactness): an
## integer-px/gridunit canvas (fit_scale_ratio()) still produces an
## odd-vs-even remainder half that can land on a .5px boundary — that would
## reintroduce a fractional-pixel blur edge on the very rung this exists to
## keep texel-exact, so the offset itself is snapped to the pixel grid.
static func center_offset(footprint_px: Vector2, viewport_px: Vector2) -> Vector2:
var raw: Vector2 = (viewport_px - footprint_px) * 0.5
return Vector2(floorf(raw.x), floorf(raw.y))
## D-255 premise (2), texel-exact: the largest INTEGER pixels-per-gridunit
## ratio `R` at which a `extent_cells`-gridunit canvas fits inside
## `viewport_px` on BOTH axes, floored at 1 (never below native resolution
## — an over-viewport canvas draws at 1 px/gridunit and crops/pans, same as
## every fixed rung's own "canvas can exceed the viewport" precedent).
## `R` is a PIXELS-PER-GRIDUNIT ratio, not a multiple of the whole footprint
## — this is the fine-grained lattice D-255's amendment (2026-07-25)
## clarifies rung-0's display ratio to be viewport-fitted-per-body against:
## GJ1c's 177x88-gridunit canvas at a 1920x1080 viewport (legend column
## reserved) lands on R=9 px/gridunit (1593x792, ~98% width), not a coarse
## multiple of the BASE 5px/gridunit footprint (885/1770/2655 — the old,
## too-coarse lattice that made a fractional escape hatch look necessary).
static func fit_scale_ratio(extent_cells: Vector2, viewport_px: Vector2) -> int:
if extent_cells.x <= 0.0 or extent_cells.y <= 0.0:
return 1
var max_x: int = int(floor(viewport_px.x / extent_cells.x))
var max_y: int = int(floor(viewport_px.y / extent_cells.y))
return maxi(1, mini(max_x, max_y))
## Convert a target pixels-per-gridunit ratio `R` (fit_scale_ratio()'s
## return) into the `_canvas.scale` multiplier applied ON TOP OF a texture
## already rendered at `base_display_ratio` px/gridunit (canvas_footprint_px()
## — the rung's own DISPLAY_RATIO_BY_RUNG entry). The multiplier itself may
## be fractional (e.g. 9/5 = 1.8) — texel-exactness is NOT about the scale
## factor being a whole number, it is about the FINAL on-screen pixel count
## per gridunit (`R`) being an exact integer, so every source texel lands on
## a whole number of screen pixels with no fractional-pixel blur boundary.
static func fit_scale_from_ratio(ratio: int, base_display_ratio: float) -> float:
return float(ratio) / maxf(base_display_ratio, 0.0001)
## Fit a fixed-rung request's extent (in gridunits) to the viewport, capped
## at FIXED_CANVAS_MAX_AXIS per axis (D-255(a)/(b)'s own budget) and at the
## rung's own display ratio — this is the CLIENT's half of "viewport-sized
@@ -206,6 +269,39 @@ static func viewport_fit_extent(viewport_px: Vector2, rung: String) -> Vector2i:
return Vector2i(w, h)
## Cap a fixed-rung request extent to the body's own region grid (T-1189):
## `derive_orbital_at_metres` wraps longitude/clamps latitude server-side, so
## a viewport-fit extent wider or taller than the body's grid makes a rung
## that SHARES Global's gridunit spacing (currently Region only, matched by
## SPACING not by name — see below) request more of the body than exists,
## producing the visible "continent repeats sideways / rows smear past the
## pole" defect (T-1183 eyeball, GJ1c). `global_extent` is the Global rung's
## own echoed canvas size — `StepCanvasRung::global_cell_counts()` server-side
## IS the body's region grid (cols = regions_per_equator(R), rows = cols/2),
## so it is exactly the cap: no unit conversion needed since Region's
## gridunit spacing equals Global's (both RUNG_SPACING_M[RUNG_GLOBAL]).
##
## Shape-generic per the ticket's own instruction: this caps ANY rung whose
## spacing matches Global's (a spacing comparison, not `rung ==
## RUNG_REGION`), so a future rung sharing that spacing is covered for free.
## Every other rung's spacing is strictly finer than Global's, so its
## viewport-fit canvas physically cannot cover more than one region's worth
## of ground per axis at any real viewport size — the guard is a no-op for
## them by construction, not by an explicit exclusion list. A non-positive
## `global_extent` axis (the Global echo genuinely hasn't arrived yet — see
## StepCanvasViewer's own cold-start fallback doc) leaves that axis
## uncapped, matching the ticket's "request uncapped and let the server
## clamp" fallback choice.
static func cap_extent_to_body(
extent: Vector2i, rung: String, global_extent: Vector2i
) -> Vector2i:
if not is_equal_approx(spacing_for_rung(rung), spacing_for_rung(RUNG_GLOBAL)):
return extent
var w: int = extent.x if global_extent.x <= 0 else mini(extent.x, global_extent.x)
var h: int = extent.y if global_extent.y <= 0 else mini(extent.y, global_extent.y)
return Vector2i(w, h)
## WASD + arrow keys, read via Input.is_key_pressed() on the PHYSICAL keycode
## (not an InputMap action) — same rationale the retired
## atlas_window_geometry.gd's own held_pan_direction() documented: this
@@ -16,8 +16,16 @@ extends Control
## - StepCanvasAnnotationLayer (Node2D child of `_canvas`, drawn AFTER the
## terrain layer): unscaled screen-space courses/settlement markers.
## Both layers live under ONE `_canvas` Node2D whose `.position` is the pan
## offset ONLY — there is no `.scale` write anywhere in this file (the whole
## point of the retirement: "there is no more zoom-scaled canvas").
## offset PLUS the T-1189/T-1192 letterbox centering offset, and whose
## `.scale` is the T-1192 Global integer-fit multiplier (1.0 for every other
## rung). This is NOT the retired `_canvas.scale` zoom model: that scale
## changed continuously, per input frame, and had to be compensated for at
## every draw call (`_zs`/`_zs_stroke`/`_zs_ring_radius`). This scale is set
## ONCE per canvas adoption (_on_canvas_ready()/resize), identically for both
## child layers via ordinary Node2D transform inheritance, and is exactly
## D-255(e)'s sanctioned "texture-to-viewport resize" — a fixed, closed-set
## display-time scale, not a per-frame invented one. See
## _recompute_canvas_transform() for the one place both fields are set.
##
## Stepped transport (D-255(a)): a discrete rung INDEX (0-5,
## StepCanvasTransport.RUNG_LADDER), never a float zoom. Mouse wheel scrolls
@@ -93,11 +101,35 @@ var _world_center: Vector2 = Vector2.ZERO
var _held_rung: String = StepCanvasTransport.RUNG_GLOBAL
var _held_extent: Vector2i = Vector2i.ZERO
# ── Pan state (position only — NO scale/zoom field anywhere) ─────────────
## T-1189: the body's own region grid (cols, rows), i.e. the LAST ADOPTED
## Global canvas's own width/height — set only in _on_canvas_ready() when
## `_held_rung == RUNG_GLOBAL` (see there). This is the cap source for
## StepCanvasTransport.cap_extent_to_body(): distinct from `_held_extent`
## (which is overwritten by whatever rung is CURRENTLY held, so it stops
## meaning "the body's grid" the moment the player descends past Global).
## ZERO until the first Global response actually arrives — see
## _request_extent()'s own cold-start fallback doc for what happens then.
var _global_body_extent: Vector2i = Vector2i.ZERO
# ── Pan state ───────────────────────────────────────────────────────────
var _view_offset: Vector2 = Vector2.ZERO
var _last_mouse_pos: Vector2 = Vector2(-1.0, -1.0)
var _app_has_focus: bool = true
## T-1192 Global fit scale — the ONE display-time scale this viewer ever
## writes (see the class doc). Always 1.0 for a fixed rung (its canvas is
## already texel-exact at its own display ratio; T-1189's letterbox only
## adds centered margin, never an extra scale). For Global, the `_canvas.scale`
## multiplier that puts the FINAL on-screen pixels-per-gridunit at the
## largest INTEGER ratio that fits the (legend-reserved) viewport, floored
## at 1 (D-255 amendment 2026-07-25 — rung-0's display ratio is
## viewport-fitted per body to an integer, never a non-integer/sub-1x
## fraction) — see StepCanvasTransport.fit_scale_ratio()'s own doc. The
## multiplier itself (`ratio / base_display_ratio`) may be a non-integer
## float — that is expected and correct, since texel-exactness is about the
## RATIO being integer, not the Node2D scale field's raw value.
var _canvas_scale: float = 1.0
# ── Overlay visibility ─────────────────────────────────────────────────────
var _overlay_visibility: Dictionary = {}
@@ -173,6 +205,7 @@ func enter(body: Dictionary, system: Dictionary) -> void:
_world_center = Vector2.ZERO
_held_rung = StepCanvasTransport.RUNG_GLOBAL
_held_extent = Vector2i.ZERO
_global_body_extent = Vector2i.ZERO # a new body has its own region grid
_view_offset = Vector2.ZERO
_request.reset()
_annotation_layer.clear_frame()
@@ -255,11 +288,28 @@ func _fire_request() -> void:
_request.request_now(get_body_id(), _held_rung, center, extent)
## T-1189: cap the viewport-fit extent to the body's own region grid BEFORE
## the request is issued, so the cache key (which includes extent) is
## consistent with what actually gets served — never cap the echo
## afterward. Cold-start fallback (documented on `_global_body_extent`
## above): if no Global response has arrived yet for this body,
## `_global_body_extent` is still ZERO and cap_extent_to_body() leaves the
## request uncapped on the affected axis — chosen over "fetch Global first"
## because the ladder is already strictly sequential (enter() always lands
## on Global before any deeper rung is reachable, RegionalScreen/D-255(a)),
## so the ONLY way to reach a Region request before the Global echo lands is
## scrolling in fast while the FIRST request (already in flight) hasn't
## returned — a real but narrow window, not the common case, and the
## viewport-fit extent is already server-clamped independently (D-255(a)/(b):
## "never trust the echo to equal the request"), so this fallback is honest,
## not silently wrong — it just misses the ADDITIONAL client-side
## sideways-repeat guard for that one request.
func _request_extent() -> Vector2i:
var viewport: Vector2 = get_rect().size
if viewport == Vector2.ZERO:
viewport = Vector2(1280.0, 720.0)
return StepCanvasTransport.viewport_fit_extent(viewport, _held_rung)
var fit: Vector2i = StepCanvasTransport.viewport_fit_extent(viewport, _held_rung)
return StepCanvasTransport.cap_extent_to_body(fit, _held_rung, _global_body_extent)
func _on_step_canvas_received(response: Dictionary) -> void:
@@ -272,14 +322,68 @@ func _on_step_canvas_received(response: Dictionary) -> void:
## hold-fetch-swap contract — Stig round-1 §2).
func _on_canvas_ready(canvas: Dictionary) -> void:
_held_extent = _request.get_held_extent()
if _held_rung == StepCanvasTransport.RUNG_GLOBAL:
_global_body_extent = _held_extent
_rebuild_terrain_texture(canvas)
_annotation_layer.set_frame(canvas, _world_center, _held_rung, _held_extent)
_recompute_canvas_transform()
_refresh_screen_header()
if _legend_panel:
_legend_panel.refresh()
queue_redraw()
## T-1189/T-1192 shared letterbox mechanism: recompute `_canvas.scale` (the
## Global integer-fit multiplier, 1.0 elsewhere) and re-center `_view_offset`
## on the FRESHLY adopted canvas's own footprint. Called once per canvas
## adoption (_on_canvas_ready(), the only place a new texture size can
## appear) and on viewport resize (the fit target itself changed) — never
## per-frame, matching the class doc's "set once per canvas adoption" scale
## discipline. Deliberately overwrites any in-progress pan: a fresh/resized
## canvas re-centers, same as the retired top-left reset it replaces (every
## `_view_offset = Vector2.ZERO` reset site below).
##
## Global reserves StepCanvasTransport.LEGEND_COLUMN_PX on the left before
## fitting/centering (T-1192: "lay the legend out beside the canvas... never
## over it") — the legend is always-visible chrome at every rung, but only
## Global's canvas is small enough at typical viewports for the naive
## full-viewport center to land it under the legend's fixed corner position
## (a fixed rung's viewport-fit canvas already fills the available area by
## construction, so its letterbox remainder is comparatively too small to
## reach the legend column in practice). Fixed rungs center in the FULL
## viewport, unchanged.
func _recompute_canvas_transform() -> void:
if _terrain_layer == null:
return # NOTIFICATION_RESIZED can fire mid-_ready(), before children exist
var raw_footprint: Vector2 = _terrain_layer.get_footprint_px()
if raw_footprint == Vector2.ZERO:
return
_canvas_scale = _letterbox_scale_for(raw_footprint)
_canvas.scale = Vector2(_canvas_scale, _canvas_scale)
_view_offset = _centered_view_offset()
_apply_transform()
## The `_canvas.scale` multiplier for a given raw (unscaled) footprint at
## the CURRENTLY held rung — Global's per-body integer pixels-per-gridunit
## fit (StepCanvasTransport.fit_scale_ratio()/fit_scale_from_ratio(),
## reserving the legend column), 1.0 for every fixed rung. Split out from
## _recompute_canvas_transform() so _centered_view_offset() (the drift-check
## baseline) can share the exact same scale decision without re-deriving it,
## keeping the two callers structurally unable to disagree.
func _letterbox_scale_for(raw_footprint: Vector2) -> float:
if _held_rung != StepCanvasTransport.RUNG_GLOBAL:
return 1.0
var base_ratio: float = StepCanvasTransport.display_ratio_for_rung(_held_rung)
var extent_cells: Vector2 = raw_footprint / maxf(base_ratio, 0.0001)
var viewport: Vector2 = get_rect().size
var available: Vector2 = Vector2(
maxf(viewport.x - StepCanvasTransport.LEGEND_COLUMN_PX, 1.0), viewport.y
)
var ratio: int = StepCanvasTransport.fit_scale_ratio(extent_cells, available)
return StepCanvasTransport.fit_scale_from_ratio(ratio, base_ratio)
func _rebuild_terrain_texture(canvas: Variant = null) -> void:
var c: Variant = canvas if canvas != null else _terrain_layer._canvas_ref
if not c is Dictionary:
@@ -339,10 +443,37 @@ func _scroll_rung(direction: int, cursor_local: Vector2) -> void:
## True while at rung 0 but the view has drifted from the canonical
## un-panned Global frame (world_center/view_offset both ZERO) — the
## condition _scroll_rung()'s hard-reset gesture fires on.
## un-panned Global frame — `world_center` ZERO (unchanged) AND
## `view_offset` at its own CENTERED baseline (T-1189/T-1192: no longer the
## literal ZERO top-left corner, now `_centered_view_offset()`'s letterbox
## position) — the condition _scroll_rung()'s hard-reset gesture fires on.
func _is_global_view_drifted() -> bool:
return _world_center != Vector2.ZERO or _view_offset != Vector2.ZERO
return _world_center != Vector2.ZERO or _view_offset != _centered_view_offset()
## The letterbox-centered `_view_offset` for the CURRENTLY held canvas
## footprint/scale — the "no pan drift" baseline _is_global_view_drifted()
## compares against, what a hard reset restores, and what
## _recompute_canvas_transform() itself applies on a fresh canvas arrival.
## T-1192: Global centers within the viewport MINUS the reserved legend
## column, then shifts right by that column so the canvas never sits under
## the legend's own fixed corner position ("beside, never over"). Falls back
## to ZERO before any canvas has arrived (matches
## `_terrain_layer.get_footprint_px()` returning ZERO pre-arrival — there is
## nothing to center yet).
func _centered_view_offset() -> Vector2:
if _terrain_layer == null:
return Vector2.ZERO # mirrors _recompute_canvas_transform()'s own pre-_ready() guard
var raw_footprint: Vector2 = _terrain_layer.get_footprint_px()
if raw_footprint == Vector2.ZERO:
return Vector2.ZERO
var is_global: bool = _held_rung == StepCanvasTransport.RUNG_GLOBAL
var reserved_left: float = StepCanvasTransport.LEGEND_COLUMN_PX if is_global else 0.0
var viewport: Vector2 = get_rect().size
var available: Vector2 = Vector2(maxf(viewport.x - reserved_left, 1.0), viewport.y)
var scale: float = _letterbox_scale_for(raw_footprint)
var scaled_footprint: Vector2 = raw_footprint * scale
return StepCanvasTransport.center_offset(scaled_footprint, available) + Vector2(reserved_left, 0.0)
## Hard reset to the Global opener (D-255(a): "a hard full-zoom-out reset to
@@ -353,7 +484,7 @@ func _reset_to_global() -> void:
_rung_index = 0
_held_rung = StepCanvasTransport.RUNG_GLOBAL
_world_center = Vector2.ZERO
_view_offset = Vector2.ZERO
_view_offset = _centered_view_offset()
_fire_request()
_refresh_screen_header()
queue_redraw()
@@ -485,6 +616,7 @@ func _notification(what: int) -> void:
_position_overlay_bar()
if _legend_panel:
_legend_panel.reposition()
_recompute_canvas_transform()
elif what == NOTIFICATION_APPLICATION_FOCUS_OUT:
_app_has_focus = false
elif what == NOTIFICATION_APPLICATION_FOCUS_IN: