fix(ui): PR #203 review round — total retirement + bin decode + wired reset

Tyre finding: the orphaned AtlasViewer cluster is now actually deleted
(atlas_viewer, atlas_marker_overlay, atlas_descend_geometry,
atlas_legend_panel — a sixth orphan found beyond the review list —
atlas_generation_proxy, atlas_generation_state; ~2,497 lines), with
reachability re-verified across preload/class_name/res:// strings,
every .tscn, and the standalone companion app. Test suites triaged,
not blanket-deleted: 5 pure AtlasOverlayColors tests relocated into
test_atlas_window_colors, the live type-identity regression guard
relocated into test_step_canvas_viewer, dead coverage deleted. A real
harness gap surfaced during diligence and RULED, not patched:
visual_scenarios/visual_capture golden shots call retired
continuous-zoom API — no shim (would resurrect what D-255 kills);
inventory recorded on re-scoped T-1157 (gate-invisible, manual
targets only).

Hoshe finding 1: decode_png_field now detects the [Error,
PackedByteArray] bin-shape from messagepack.gd explicitly — a genuine
msgpack bin payload decodes correctly instead of silently collapsing
to [0,0]; test built from a real round-tripped bin decode.

Hoshe finding 2: the hard zoom-out reset is wired — ascend at rung 0
with a drifted view triggers _reset_to_global (the restored HARD
condition), behavioral tests through the real input path.

Notes folded: refloat + edge-scroll test coverage, legend smoke suite,
Vector2i narrowing-safety comment with computed headroom.

gdlint clean on touched files; full client suite 3364/3364.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 09:32:52 +02:00
co-authored by Claude Fable 5
parent d7fc26bd2a
commit 76d82a6a51
16 changed files with 528 additions and 3831 deletions
@@ -1,201 +0,0 @@
extends RefCounted
## Pure geometry helpers for AtlasViewer's T-1138 descent affordance (D-226
## T-1124 amendment §5 entry revision) — factored out to keep atlas_viewer.gd
## under gdlint's max-file-lines cap, same rationale/shape as
## atlas_overlay_colors.gd's split from atlas_marker_overlay.gd (draw_line()/
## draw_string() are CanvasItem instance methods called implicitly on `self`,
## so the actual draw calls stay on AtlasViewer — only the pure lookups/math
## that decide WHERE/WHAT to draw move here):
## const AtlasDescendGeometry := preload("res://ui/implant/apps/atlas/atlas_descend_geometry.gd")
##
## D-243: 2,048 m per district side — the source-canonical unit the server's
## derive_district()/DISTRICT_WINDOW_DEFAULT_N (32) both key off of.
const DISTRICT_M: float = 2048.0
const DISTRICT_WINDOW_DEFAULT_N: int = 32
## Fixed on-screen reticle size (px) — deliberately NOT scaled to the
## window's true planetary footprint.
##
## The true footprint of a DISTRICT_WINDOW_DEFAULT_N=32 window is
## 32 * 2.048 km = ~65.5 km per side (~131 km at the n=64 cap) — at every
## zoom level AtlasViewer's _fit_to_view() ever produces for a whole-planet
## heightmap (MAX_ZOOM=8.0 on a texture that already spans the full body),
## that distance is on the order of a handful of PIXELS. A true-extent
## rectangle would therefore be visually indistinguishable from a dot
## regardless of zoom — not an honest representation, just an illegible one;
## the amendment explicitly rejects "implying more coverage than real" but a
## sub-pixel rectangle fails the OPPOSITE way (implying almost no coverage,
## which is equally dishonest about what a click actually captures).
##
## The resolution (D-226 T-1124 amendment §5's open design point, resolved
## here): a small FIXED-SIZE bracket reticle (reads clearly at any zoom, same
## idiom as the marker overlay's fixed-size POI glyphs elsewhere on this map)
## plus a text label giving the REAL extent in km — "honest" comes from the
## label's number, not from the reticle's pixel size pretending to be to
## scale. This is a reticle, explicitly not scaled to true size, with the
## real extent stated next to it — the amendment's second named option
## (chosen over a true-extent rectangle + zoom-in cut).
const DESCEND_RETICLE_SIZE: float = 28.0
const COLOR_DESCEND_RETICLE: Color = Color(0.70, 0.88, 1.0, 0.85) # matches COLOR_GATE_MARKER family
## The eight line segments (as [from, to] pairs) for the reticle's four
## L-shaped bracket corners — reads as "this is a bounded region", distinct
## from the circular city-marker glyphs and diamond gate markers already on
## this map (D-226's hue=type/shape=identity instinct applied to interaction
## affordances). Flat segment-pair array so the caller's draw_line() loop is
## a one-liner, not a struct AtlasViewer needs to know the shape of.
static func reticle_segments(center: Vector2) -> Array:
var half: float = DESCEND_RETICLE_SIZE * 0.5
var arm: float = half * 0.5
var corners: Array = [
center + Vector2(-half, -half),
center + Vector2(half, -half),
center + Vector2(half, half),
center + Vector2(-half, half),
]
var h_dirs: Array = [Vector2(1, 0), Vector2(-1, 0), Vector2(-1, 0), Vector2(1, 0)]
var v_dirs: Array = [Vector2(0, 1), Vector2(0, 1), Vector2(0, -1), Vector2(0, -1)]
var segments: Array = []
for i in range(4):
segments.append([corners[i], corners[i] + h_dirs[i] * arm])
segments.append([corners[i], corners[i] + v_dirs[i] * arm])
return segments
## Label position (offset from the reticle center, to the right of it) + the
## real-extent text — "~65 x 65 km" for the default n=32 window.
static func reticle_label(center: Vector2) -> Dictionary:
var half: float = DESCEND_RETICLE_SIZE * 0.5
var extent_km: float = float(DISTRICT_WINDOW_DEFAULT_N) * DISTRICT_M / 1000.0
return {
"position": center + Vector2(half + 6.0, 4.0),
"text": "~%.0f × %.0f km" % [extent_km, extent_km],
}
## 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
## mapping (body has a radius) is:
## px = ((dx * DISTRICT_M) / circumference_m mod 1.0) * tex_w
## py = (0.5 + clamp(dy * DISTRICT_M / meridian_m, -0.5, 0.5)) * ta.h.saturating_sub(1)
## (district_profile.rs:1436 — note `.saturating_sub(1)`, NOT a bare `ta.h`;
## confirmed against the ground-truth inverse aliveness_probe.rs:511 too:
## `row / (ta_h - 1) - 0.5`). Columns and rows are DELIBERATELY asymmetric:
## longitude WRAPS (rem_euclid), so a column has no "last pixel" edge case and
## divides by the plain width; latitude CLAMPS at the poles, so row 0 and row
## (h-1) are real, distinct endpoints (the north/south pole pixels) and the
## division must land exactly on them — dividing by `tex_h` instead of
## `tex_h - 1` introduces a systematic drift that grows with |lat_frac|
## (worst at the poles, invisible only at the exact equator row, `tex_h*0.5`,
## where the two formulas round identically). This is why inverting is "pixel
## fraction * district count" for columns but NOT a symmetric operation for
## rows — the row inverse must undo the SAME -1 the forward map applied.
##
## The (wx/circumference_m, wy/meridian_m) fractions are linear scalings of
## the same world-metre quantity the equatorial/meridian district COUNT
## already IS (district_cols = round(circumference_m / DISTRICT_M), the same
## value build_district_grid()'s `cols` converges to for a body tiled
## edge-to-edge) — so inverting is "pixel fraction * district count", not a
## re-derivation of the server's geodesy. Self-contained: does NOT depend on
## district_grid (the whole-body layer) having arrived yet, so descent works
## immediately on entry even before that async layer resolves. The "no
## radius" branch (tiny test bodies, body_radius_km absent/<=0) mirrors the
## server's own fallback: the district grid IS the heightmap grid 1:1.
static func district_pos_at(
canvas_pt: Vector2, tex_w: float, tex_h: float, body_radius_km: float
) -> Vector2i:
if tex_w <= 0.0 or tex_h <= 0.0:
return Vector2i.ZERO
if body_radius_km <= 0.0:
return Vector2i(roundi(canvas_pt.x), roundi(canvas_pt.y))
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).
# Guarded the same way the server's own inverse (aliveness_probe.rs:510,
# `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 * 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
@@ -1,126 +0,0 @@
extends Node
## Generation-cascade layer-stream proxy client for AtlasViewer (#960, D-225).
## Extracted from atlas_viewer.gd (T-1118) to keep that file under the gdlint
## max-file-lines cap — this is the whole "poll the proxy, retry on Pending,
## show the diegetic GENERATING indicator, dispatch each layer field to
## AtlasGenerationState on Ready" responsibility, self-contained apart from
## reading the owning viewer's `_body`/`size` and calling its
## set_generation_*() accessors.
##
## This script has no `class_name` on purpose, matching atlas_overlay_bar.gd/
## atlas_legend_panel.gd (review #8 there): the owner (AtlasViewer) passes the
## viewer reference to _init(), and a `class_name` + required-arg _init()
## combo is a Godot editor footgun. `extends Node` (not RefCounted) because
## this owns a child Control (the pending indicator) and needs get_tree() for
## the retry timer — added as a child via
## load("res://ui/implant/apps/atlas/atlas_generation_proxy.gd").new(self).
# #960: Layer-1 proxy re-poll. The proxy returns Pending on a cache miss and
# generates in the background (D-225); the client re-requests until Ready.
const GEN_RETRY_DELAY: float = 0.5
const GEN_MAX_RETRIES: int = 20 # ~10s ceiling before giving up
var _viewer = null # AtlasViewer (untyped to avoid cyclic ref)
var _pending: bool = false # awaiting a Layer1 response (re-polls on Pending)
var _retries: int = 0
var _indicator = null # ImplantPending — loaded by path, not a class_name dep
func _init(viewer_ref = null) -> void:
_viewer = viewer_ref
func _ready() -> void:
if _viewer == null:
return
# Loaded by path (not `ImplantPending.new()`) so a stale global-class cache
# — e.g. a running session that hasn't re-imported after this class was
# added — can't fail to parse AtlasViewer and break the atlas from opening
# (#960).
_indicator = load("res://ui/implant/implant_pending.gd").new()
_indicator.name = "GenPending"
_viewer.add_child(_indicator)
_indicator.apply_implant_theme(_viewer.get_implant_theme())
_viewer.resized.connect(reposition_indicator)
reposition_indicator()
## Reset polling state for a fresh show_body() call — the caller is
## responsible for also resetting the layer data itself
## (AtlasGenerationState.reset_layer1()).
func reset() -> void:
_pending = false
_retries = 0
if _indicator:
_indicator.stop()
## Request the body's Layer-1 cascade output from the server proxy. No-op in
## test mode (SimBridge has no server connection) — the overlays simply stay
## empty, which is the correct serverless behavior.
func request(body_id: String) -> void:
if body_id.is_empty():
return
_pending = true
SimBridge.request_atlas_layers(body_id)
## Handle a Layer-1 response. Ignores responses for a stale body (the user
## navigated away). On Pending the proxy is still generating, so we re-request
## after a short delay until Ready or the retry ceiling.
func on_response(response: Dictionary, current_body_id: String) -> void:
if str(response.get("body_id", "")) != current_body_id:
return
match str(response.get("status", "")):
"Ready":
_pending = false
_retries = 0
_set_indicator(false)
_viewer.set_generation_layer1(response.get("layer1"))
_viewer.set_generation_district_grid(response.get("district_grid"))
_viewer.set_generation_road_graph(response.get("road_graph"))
_viewer.set_generation_settlements(response.get("settlements"))
_viewer.set_generation_region_grid(response.get("region_grid"))
_viewer.set_generation_quarter_footprints(response.get("quarter_footprints"))
"Pending":
if _retries < GEN_MAX_RETRIES:
_retries += 1
_set_indicator(true)
_schedule_retry(current_body_id)
else:
_pending = false # gave up — overlays stay empty
_set_indicator(false)
_:
_pending = false # NotFound / Error — nothing to draw
_set_indicator(false)
func _schedule_retry(body_id: String) -> void:
var timer := get_tree().create_timer(GEN_RETRY_DELAY)
timer.timeout.connect(
func() -> void:
# Re-request only if still on the same body and still waiting.
if _pending and _viewer.get_body_id() == body_id:
SimBridge.request_atlas_layers(body_id)
)
## Show/hide the diegetic pending indicator. Starting only when not already
## visible avoids resetting the sweep on every re-poll.
func _set_indicator(on: bool) -> void:
if _indicator == null:
return
if on:
if not _indicator.visible:
reposition_indicator()
_indicator.start("GENERATING LAYER 1")
else:
_indicator.stop()
func reposition_indicator() -> void:
if _indicator == null or _viewer == null:
return
var sz: Vector2 = _viewer.size
_indicator.position = Vector2((sz.x - _indicator.DEFAULT_WIDTH) * 0.5, sz.y * 0.45)
@@ -1,105 +0,0 @@
extends RefCounted
## Generation-cascade layer state for AtlasViewer (#960, D-225).
##
## Extracted from atlas_viewer.gd (T-1118/T-1119) to keep that file under the
## gdlint max-file-lines cap while it keeps growing a new field+accessor pair
## per generation layer (layer1, district_grid, road_graph, settlements,
## region_grid, quarter_footprints — the AtlasLayerResponse growth-ceiling
## note in the D-226 T-1112 amendment names region_grid/quarter_footprints as
## the last two candidate sibling Option fields). One `_generation_*` field +
## one get/set pair per layer, all following the same "store + redraw the
## overlay" shape — collecting them here means that shape is written once
## (in _set) instead of once per accessor pair.
##
## AtlasViewer holds one instance (`_gen_state`) and exposes the SAME public
## get_generation_*()/set_generation_*() method names it always has —
## external callers (atlas_marker_overlay.gd, test_atlas_overlays.gd) are
## unaffected; only the storage moved. Consumed via explicit load() by path
## (no class_name), matching atlas_legend_panel.gd/atlas_overlay_bar.gd
## (review #8 there): AtlasViewer is itself referenced by class_name in
## other scripts, and a second global class_name in this cluster is an
## unnecessary addition to the global class cache.
var _overlay_node: Node2D = null # set once by AtlasViewer._ready() (the redraw target)
var _layer1: Variant = null # #960: Layer1Output from the proxy (D-225)
var _district_grid: Variant = null # T-1046: coarse DistrictGridLayer (D-226)
var _road_graph: Variant = null # T-960: RoadGraph layer from the proxy (D-225)
var _settlements: Variant = null # T-960: settlement placements from the proxy (D-225)
var _region_grid: Variant = null # T-1118: RegionGridLayer climate grid (D-226/T-1113)
var _quarter_footprints: Variant = null # T-1119: QuarterFootprintLayer (D-226 T-1112 amendment)
func bind_overlay_node(overlay_node: Node2D) -> void:
_overlay_node = overlay_node
func _redraw() -> void:
if _overlay_node:
_overlay_node.queue_redraw()
## Reset every layer to its pre-body-load default. Called from
## AtlasViewer.show_body() so a body switch doesn't inherit the previous
## body's generation data.
func reset_layer1() -> void:
_layer1 = null
func set_layer1(layer1: Variant) -> void:
_layer1 = layer1
_redraw()
func get_layer1() -> Variant:
return _layer1
func set_district_grid(grid: Variant) -> void:
_district_grid = grid
_redraw()
func get_district_grid() -> Variant:
return _district_grid
func set_road_graph(graph: Variant) -> void:
_road_graph = graph
_redraw()
func get_road_graph() -> Variant:
return _road_graph
func set_settlements(settlements: Variant) -> void:
_settlements = settlements
_redraw()
func get_settlements() -> Variant:
return _settlements
## T-1118: store the region climate grid (RegionGridLayer) from the proxy and
## redraw. The marker overlay reads it via AtlasViewer.get_generation_region_grid().
func set_region_grid(grid: Variant) -> void:
_region_grid = grid
_redraw()
func get_region_grid() -> Variant:
return _region_grid
## T-1119: store the L4 quarter-footprint aggregates (QuarterFootprintLayer)
## from the proxy and redraw. The marker overlay reads it via
## AtlasViewer.get_generation_quarter_footprints().
func set_quarter_footprints(footprints: Variant) -> void:
_quarter_footprints = footprints
_redraw()
func get_quarter_footprints() -> Variant:
return _quarter_footprints
@@ -1,192 +0,0 @@
extends ImplantPanel
## Left-side generation-overlay legend (D-226 item 3, T-960) — the shape/color
## key for the generation overlay group (attractor types, sub-biome colors,
## district morphology, road/rail authority + style, settlement size/capital).
##
## This script has no `class_name` on purpose, mirroring atlas_overlay_bar.gd
## (review #8 there): the owner (AtlasViewer) passes the viewer reference to
## _init(), and a `class_name` + required-arg _init() combo is a Godot editor
## footgun. Instance it via
## load("res://ui/implant/apps/atlas/atlas_legend_panel.gd").new(self).
##
## Data-driven (GENERATION_LEGEND below): one spec entry per generation
## overlay id — multiple entries may share an id (e.g. gen_l1_attractors has
## both a shape key and a color key, D-226's shape-vs-color separation taught
## the same way here as it is drawn on the map). Adding a future layer's
## legend is a new table row, never a layout change. refresh() shows only the
## sections whose overlay is currently toggled on, and hides the whole panel
## when none are active (invisible when not needed).
const PANEL_MARGIN: float = 16.0
const LEGEND_PANEL_WIDTH: float = 260.0
# Generation overlay colors (T-960) — kept in sync with the actual render
# values in atlas_marker_overlay.gd; duplicated rather than cross-referenced,
# matching the existing COLOR_ROAD/COLOR_RAIL/COLOR_CITY precedent shared
# between atlas_viewer.gd and atlas_marker_overlay.gd (each file owns its own
# reading of the palette: this one for the legend, the marker overlay for the
# draw calls).
const COLOR_ROAD_ADMINISTRATIVE: Color = Color(0.45, 0.65, 0.90, 0.85)
const COLOR_ROAD_CORPORATE: Color = Color(0.85, 0.65, 0.20, 0.85)
const COLOR_ROAD_COMMUNAL: Color = Color(0.45, 0.75, 0.50, 0.85)
const COLOR_ROAD_TRADE: Color = Color(0.85, 0.60, 0.30, 0.85)
const COLOR_ROAD_ABANDONED: Color = Color(0.45, 0.42, 0.38, 0.65)
const COLOR_SETTLEMENT_GEN: Color = Color(0.94, 0.82, 0.38, 1.0)
const COLOR_SETTLEMENT_CAPITAL_GEN: Color = Color(1.0, 0.92, 0.55, 1.0)
# T-1118 — region climate grid cold/hot ramp endpoints (matches
# atlas_overlay_colors.gd's COLOR_REGION_TEMP_COLD/COLOR_REGION_TEMP_HOT).
const COLOR_REGION_TEMP_COLD_GEN: Color = Color(0.25, 0.45, 0.85, 0.60)
const COLOR_REGION_TEMP_HOT_GEN: Color = Color(0.90, 0.25, 0.20, 0.60)
# T-1119 — quarter-footprint density ramp endpoints, D-226 T-1112 amendment
# SS3's literal legend spec (matches atlas_overlay_colors.gd's
# COLOR_QUARTER_LOW_DENSITY/COLOR_QUARTER_HIGH_DENSITY).
const COLOR_QUARTER_LOW_DENSITY_GEN: Color = Color(0.55, 0.48, 0.30, 0.6)
const COLOR_QUARTER_HIGH_DENSITY_GEN: Color = Color(0.94, 0.82, 0.38, 1.0)
## One entry per generation-overlay id. "color": Color.TRANSPARENT means
## "shape/style carries the meaning here, let the theme's dim text color
## apply" — used for every row where color is NOT the encoded axis.
const GENERATION_LEGEND: Array = [
{
"overlay_id": "gen_l1_rivers",
"title": "RIVERS — L1",
"rows": [
{"glyph": "━", "color": Color(0.353, 0.647, 0.776, 1.0), "label": "channel / confluence"},
{"glyph": "◎", "color": Color(0.353, 0.647, 0.776, 1.0), "label": "sea mouth"},
],
},
{
"overlay_id": "gen_l1_basins",
"title": "DRAINAGE BASINS — L1",
"rows": [
{"glyph": "▭", "color": Color(0.45, 0.65, 0.85, 0.70), "label": "basin fill + boundary"},
],
},
{
"overlay_id": "gen_l1_attractors",
"title": "ATTRACTORS — L1 (shape = type)",
"rows": [
{"glyph": "●", "color": Color.TRANSPARENT, "label": "river mouth"},
{"glyph": "◑", "color": Color.TRANSPARENT, "label": "coastal access"},
{"glyph": "◆", "color": Color.TRANSPARENT, "label": "river crossing"},
{"glyph": "▼", "color": Color.TRANSPARENT, "label": "valley floor"},
{"glyph": "▲", "color": Color.TRANSPARENT, "label": "pass entrance"},
{"glyph": "○", "color": Color.TRANSPARENT, "label": "lake shore"},
{"glyph": "■", "color": Color.TRANSPARENT, "label": "plain center"},
],
},
{
"overlay_id": "gen_l1_attractors",
"title": "SUB-BIOME — L1 (color)",
"rows": [
{"glyph": "●", "color": Color(0.25, 0.72, 0.65, 0.90), "label": "tropical / coastal"},
{"glyph": "●", "color": Color(0.45, 0.68, 0.45, 0.90), "label": "temperate"},
{"glyph": "●", "color": Color(0.78, 0.62, 0.35, 0.90), "label": "arid"},
{"glyph": "●", "color": Color(0.52, 0.58, 0.72, 0.90), "label": "alpine"},
{"glyph": "●", "color": Color(0.40, 0.60, 0.52, 0.90), "label": "wetland"},
{"glyph": "●", "color": Color(0.55, 0.68, 0.82, 0.90), "label": "cold"},
],
},
{
"overlay_id": "gen_region_grid",
"title": "REGION CLIMATE — mean temperature (~205 km cells)",
"rows": [
{"glyph": "▦", "color": COLOR_REGION_TEMP_COLD_GEN, "label": "cold"},
{"glyph": "▦", "color": COLOR_REGION_TEMP_HOT_GEN, "label": "hot"},
{"glyph": "▦", "color": Color.TRANSPARENT, "label": "airless — no reading (skipped)"},
],
},
{
"overlay_id": "gen_district",
"title": "DISTRICT MORPHOLOGY — coarse",
"rows": [
{"glyph": "▦", "color": Color.TRANSPARENT, "label": "terrain-colored fill (17 zones)"},
],
},
{
"overlay_id": "gen_l2_roads",
"title": "ROADS/RAIL — L2 (color = authority)",
"rows": [
{"glyph": "━", "color": COLOR_ROAD_ADMINISTRATIVE, "label": "administrative"},
{"glyph": "━", "color": COLOR_ROAD_CORPORATE, "label": "corporate"},
{"glyph": "━", "color": COLOR_ROAD_COMMUNAL, "label": "communal"},
{"glyph": "━", "color": COLOR_ROAD_TRADE, "label": "trade"},
{"glyph": "━", "color": COLOR_ROAD_ABANDONED, "label": "abandoned"},
],
},
{
"overlay_id": "gen_l2_roads",
"title": "LINE STYLE (road vs rail)",
"rows": [
{"glyph": "━", "color": Color.TRANSPARENT, "label": "road (solid)"},
{"glyph": "┄", "color": Color.TRANSPARENT, "label": "rail (dashed)"},
{"glyph": "◇", "color": Color.TRANSPARENT, "label": "junction (3+ ways)"},
],
},
{
"overlay_id": "gen_l3_settlements",
"title": "SETTLEMENTS — L3 (size = population)",
"rows": [
{"glyph": "●", "color": COLOR_SETTLEMENT_GEN, "label": "settlement"},
{"glyph": "★", "color": COLOR_SETTLEMENT_CAPITAL_GEN, "label": "capital / major hub"},
],
},
{
"overlay_id": "gen_l4_quarters",
"title": "QUARTER FOOTPRINT — L4 (color = density, shape = dominant type)",
"rows": [
{"glyph": "▪", "color": COLOR_QUARTER_LOW_DENSITY_GEN, "label": "low density"},
{"glyph": "▪", "color": COLOR_QUARTER_HIGH_DENSITY_GEN, "label": "high density"},
{"glyph": "◪", "color": Color.TRANSPARENT, "label": "commercial (corner tab, top-right)"},
{"glyph": "◩", "color": Color.TRANSPARENT, "label": "industrial (corner tab, bottom-right)"},
{"glyph": "◈", "color": Color.TRANSPARENT, "label": "administrative (diamond cutout)"},
],
},
]
var _viewer = null # AtlasViewer (untyped to avoid cyclic ref)
func _init(viewer_ref = null) -> void:
_viewer = viewer_ref
custom_minimum_size.x = LEGEND_PANEL_WIDTH
mouse_filter = Control.MOUSE_FILTER_IGNORE
visible = false
func reposition() -> void:
position = Vector2(PANEL_MARGIN, 60.0)
## Rebuilds from GENERATION_LEGEND, showing only the sections whose overlay is
## currently toggled on — invisible when no generation overlay is active,
## updates every time AtlasViewer.set_overlay_visible() runs.
func refresh() -> void:
if _viewer == null:
return
var active_specs: Array = []
for spec: Dictionary in GENERATION_LEGEND:
if _viewer.is_overlay_visible(str(spec.get("overlay_id", ""))):
active_specs.append(spec)
clear()
visible = not active_specs.is_empty()
if active_specs.is_empty():
reset_to_content_size()
return
add_component(
ImplantHeader.new("GENERATION LEGEND", "%d layer(s) active" % active_specs.size())
)
add_component(ImplantSeparator.new())
for i: int in range(active_specs.size()):
var spec: Dictionary = active_specs[i]
add_component(ImplantTextBlock.new(str(spec.get("title", ""))))
for row: Dictionary in spec.get("rows", []):
var text: String = "%s %s" % [str(row.get("glyph", "-")), str(row.get("label", ""))]
add_component(ImplantDataRow.new(text, row.get("color", Color.TRANSPARENT)))
if i < active_specs.size() - 1:
add_component(ImplantSeparator.new())
reposition()
reset_to_content_size()
@@ -1,873 +0,0 @@
class_name AtlasMarkerOverlay
extends Node2D
## Draws heightmap + markers for #835 AtlasViewer. Child of AtlasViewer._canvas
## so it inherits the pan/zoom transform. Draws in texture-native coordinates.
##
## Overlay layers (#836 toggles via viewer._overlay_visibility):
## terrain heightmap texture itself
## political_zones currency-zone color bands (coarse)
## infrastructure roads + railroads
## named_features labels for rivers, oceans, mountain ranges
## gate_markers gate-terminal POIs
## population_density (toggleable) density heatmap placeholder
## production_zones (toggleable) production zone outlines placeholder
## shadow_economy (toggleable) shadow-zone broad bands placeholder
## corp_presence (toggleable) Tier 1 corp dots placeholder
## stockpile_weeks (locked) gated by corporate contact
## production_vs_baseline (locked) gated by insider access
## gen_l2_roads (toggleable) T-960 — road/rail graph, color = authority
## gen_l3_settlements (toggleable) T-960 — settlement placements, size = population
const COLOR_HEIGHTMAP_TINT: Color = Color(0.85, 0.88, 0.95, 1.0)
const COLOR_POLITICAL: Color = Color(0.25, 0.50, 0.75, 0.14)
const COLOR_ROAD: Color = Color(0.85, 0.60, 0.30, 0.85)
const COLOR_RAIL: Color = Color(0.45, 0.55, 0.70, 0.85)
const COLOR_CITY: Color = Color(0.94, 0.82, 0.38, 1.0)
const COLOR_CITY_HOVER: Color = Color(1.0, 1.0, 1.0, 1.0)
const COLOR_CITY_SELECTED: Color = Color(1.0, 0.95, 0.60, 1.0)
const COLOR_GATE: Color = Color(0.70, 0.88, 1.0, 1.0)
const COLOR_POI: Color = Color(0.45, 0.75, 0.85, 0.90)
const COLOR_FEATURE_LABEL: Color = Color(0.78, 0.82, 0.92, 0.65)
const COLOR_POP_HEAT: Color = Color(0.95, 0.35, 0.25, 0.22)
const COLOR_PRODUCTION: Color = Color(0.40, 0.80, 0.55, 0.18)
const COLOR_SHADOW: Color = Color(0.35, 0.20, 0.50, 0.22)
const COLOR_CORP: Color = Color(0.85, 0.65, 0.20, 0.75)
# Generation overlays (#960, D-225, Araminta's encoding).
# Rivers/mouths use the reliefmap's own water colour (sampled median ocean blue)
# so river lines blend seamlessly into the surface water bodies instead of
# reading as a distinct-coloured line flowing onto the sea.
const COLOR_GEN_RIVER: Color = Color(0.353, 0.647, 0.776, 1.0)
const COLOR_GEN_MOUTH: Color = Color(0.353, 0.647, 0.776, 1.0)
const COLOR_GEN_BASIN_FILL: Color = Color(0.20, 0.35, 0.55, 0.06)
const COLOR_GEN_BASIN_LINE: Color = Color(0.45, 0.65, 0.85, 0.45)
const GEN_ATTRACTOR_MIN_STRENGTH: float = 0.15
## Pure color-ramp/shape-selection helpers (morphology, sub-biome, road
## authority, region temp, quarter density/notch) — factored into
## atlas_overlay_colors.gd (T-1118) to stay under gdlint's max-file-lines,
## same rationale as atlas_format.gd's static-function + preload pattern.
## That file owns the actual constant tables; this file only keeps constants
## with no decision function wrapping them (e.g. COLOR_SETTLEMENT — used
## directly, not looked up).
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
const REGION_TEMP_NONE_DC: int = AtlasOverlayColors.REGION_TEMP_NONE_DC
# Province boundaries (D-205, #927)
const COLOR_PROVINCE_BORDER: Color = Color(0.45, 0.65, 0.85, 0.55)
const COLOR_PROVINCE_FILL: Color = Color(0.25, 0.45, 0.65, 0.08)
const PROVINCE_BORDER_WIDTH: float = 1.2
# T-960 L2 — road/rail graph (D-211, T-1038). MaintenanceAuthority colors —
# Corporate reuses COLOR_CORP's amber and Trade reuses the base D-191
# COLOR_ROAD tan (long-haul trade routes ARE the base "road" concept, now
# split out by authority) so the new encoding stays visually consistent with
# the existing overlay palette instead of introducing an unrelated hue set.
const COLOR_ROAD_JUNCTION: Color = Color(0.85, 0.85, 0.90, 0.80)
const ROAD_JUNCTION_MIN_DEGREE: int = 3 # mirrors server's JUNCTION_DEGREE (road_graph.rs)
# T-960 L3 — settlement placements (D-211, T-955's CityPlacement). Regular
# settlements reuse the legacy COLOR_CITY gold (same concept — "this is a
# city" reads consistently everywhere on the Atlas); capitals get a brighter
# variant AND a distinct star shape (D-226 shape-encodes-identity).
const COLOR_SETTLEMENT: Color = Color(0.94, 0.82, 0.38, 1.0)
const COLOR_SETTLEMENT_CAPITAL: Color = Color(1.0, 0.92, 0.55, 1.0)
# SettlementEntry.size_class (dudley-atlas-server contract, 2026-07-14) — the
# same D-211 Tier A/B/C cutoffs already used for placement, now a render size.
const SETTLEMENT_RADII: Dictionary = {"Major": 6.0, "Standard": 4.0, "Minor": 2.5}
const SETTLEMENT_RADIUS_DEFAULT: float = 2.5
const SETTLEMENT_LABEL_MIN_ZOOM: float = 2.0
const RAIL_DASH_ON: float = 6.0
const RAIL_DASH_OFF: float = 4.0
const PRODUCTION_FUNCTIONS: Array = [
"industrial",
"industry",
"manufacturing",
"extraction",
"mining",
"refinery",
"foundry",
"shipyard",
"production",
]
var viewer = null # AtlasViewer (untyped to avoid cyclic ref)
# Generation-overlay coordinate mapping (#960): set in _draw before the gen
# layers are drawn. Positions are [row, col] in the Layer-1 working grid
# (_gen_grid_*), mapped onto the displayed heightmap texture (_gen_tex_*).
var _gen_grid_w: float = 0.0
var _gen_grid_h: float = 0.0
var _gen_tex_w: float = 0.0
var _gen_tex_h: float = 0.0
func _draw() -> void:
if viewer == null:
return
var tex: Texture2D = viewer.get_heightmap_texture()
if tex == null:
return
var tex_w: float = float(tex.get_width())
var tex_h: float = float(tex.get_height())
var markers: Dictionary = viewer.get_markers()
# Terrain (heightmap) — always first
if viewer.is_overlay_visible("terrain"):
draw_texture_rect(
tex, Rect2(Vector2.ZERO, Vector2(tex_w, tex_h)), false, COLOR_HEIGHTMAP_TINT
)
else:
draw_rect(Rect2(Vector2.ZERO, Vector2(tex_w, tex_h)), Color(0.05, 0.07, 0.10, 1.0))
# Political zones — province boundaries from drainage analysis
if viewer.is_overlay_visible("political_zones"):
_draw_province_boundaries(markers)
# Infrastructure (roads + rail)
if viewer.is_overlay_visible("infrastructure"):
_draw_roads(markers)
_draw_rails(markers)
# Named features (rivers, oceans, mountain ranges)
if viewer.is_overlay_visible("named_features"):
_draw_named_features(markers)
# Toggleable placeholders — drawn only when populated
if viewer.is_overlay_visible("population_density"):
_draw_population_density(markers)
if viewer.is_overlay_visible("production_zones"):
_draw_production_zones(markers)
if viewer.is_overlay_visible("shadow_economy"):
_draw_shadow_economy(markers)
if viewer.is_overlay_visible("corp_presence"):
_draw_corp_presence(markers)
# Generation: region climate grid (T-1118, D-243) — the coarsest area fill
# (~205 km cells), drawn first among generation overlays so the district
# morphology grid and every finer layer below reads on top of it as
# background climate context.
if viewer.is_overlay_visible("gen_region_grid"):
var region_grid: Variant = viewer.get_generation_region_grid()
if region_grid is Dictionary:
_draw_gen_region_grid(region_grid, tex_w, tex_h)
# Generation: district morphology grid (T-1046, D-226) — coarse area fill,
# drawn under the Layer-1 line/point overlays.
if viewer.is_overlay_visible("gen_district"):
var grid: Variant = viewer.get_generation_district_grid()
if grid is Dictionary:
_draw_gen_district(grid, tex_w, tex_h)
# Generation-cascade layers (#960, D-225) — from the proxied Layer1Output.
# Draw order: basins (area) under rivers under attractors (point anchors).
var layer1: Variant = viewer.get_generation_layer1()
if layer1 is Dictionary:
# The Layer1Output carries the working-grid dims its positions live in
# (#960). Map from those, NOT the markers.json grid (_grid_w/_grid_h).
_gen_grid_w = float(layer1.get("grid_w", 0))
_gen_grid_h = float(layer1.get("grid_h", 0))
_gen_tex_w = tex_w
_gen_tex_h = tex_h
if _gen_grid_w > 0.0 and _gen_grid_h > 0.0:
if viewer.is_overlay_visible("gen_l1_basins"):
_draw_gen_basins(layer1)
if viewer.is_overlay_visible("gen_l1_rivers"):
_draw_gen_rivers(layer1)
if viewer.is_overlay_visible("gen_l1_attractors"):
_draw_gen_attractors(layer1)
# L2 roads / L3 settlements share the same working grid (T-960) —
# both are positioned from the same cascade run as Layer1, so they
# reuse the _gen_grid_*/_gen_tex_* mapping set up above.
if viewer.is_overlay_visible("gen_l2_roads"):
var road_graph: Variant = viewer.get_generation_road_graph()
if road_graph is Dictionary:
_draw_gen_roads(road_graph)
if viewer.is_overlay_visible("gen_l3_settlements"):
var settlements: Variant = viewer.get_generation_settlements()
if settlements != null:
_draw_gen_settlements(settlements)
# T-1119: drawn immediately after gen_l3_settlements so it reads
# as "on top of" the city dot it annotates (D-226 T-1112 SS3),
# and needs the SAME settlements payload for the city_id->
# position join (quarter footprints carry no position of
# their own).
if viewer.is_overlay_visible("gen_l4_quarters"):
var quarters: Variant = viewer.get_generation_quarter_footprints()
if quarters is Dictionary:
_draw_gen_quarter_footprints(quarters, settlements)
# POIs (non-gate first, then gates on top if enabled)
_draw_pois(markers)
# Gate markers
if viewer.is_overlay_visible("gate_markers"):
_draw_gate_markers(markers)
# Cities last so labels sit on top
_draw_cities(markers)
# =============================================================================
# Feature drawing
# =============================================================================
func _draw_roads(markers: Dictionary) -> void:
var roads: Array = markers.get("roads", [])
for r: Dictionary in roads:
var path: Array = r.get("path", [])
if path.size() < 2:
continue
var points: PackedVector2Array = _path_to_canvas(path)
draw_polyline(points, COLOR_ROAD, 1.5, true)
func _draw_rails(markers: Dictionary) -> void:
var rails: Array = markers.get("railroads", [])
for r: Dictionary in rails:
var path: Array = r.get("path", [])
if path.size() < 2:
continue
var points: PackedVector2Array = _path_to_canvas(path)
_draw_dashed_polyline(points, COLOR_RAIL, 1.0)
func _draw_named_features(markers: Dictionary) -> void:
var font := ThemeDB.fallback_font
var fs: int = 8
for ocean: Dictionary in markers.get("oceans", []):
var label: String = ocean.get("name", "") if ocean.get("name") else ""
if label.is_empty():
continue
var p: Vector2 = _center_to_canvas(ocean.get("center"))
draw_string(
font, p, label.to_upper(), HORIZONTAL_ALIGNMENT_CENTER, -1, fs, COLOR_FEATURE_LABEL
)
for mtn: Dictionary in markers.get("mountain_ranges", []):
var label: String = mtn.get("name", "") if mtn.get("name") else ""
if label.is_empty():
continue
var p: Vector2 = _center_to_canvas(mtn.get("center"))
draw_string(font, p, label, HORIZONTAL_ALIGNMENT_CENTER, -1, fs, COLOR_FEATURE_LABEL)
for river: Dictionary in markers.get("rivers", []):
var label: String = river.get("name", "") if river.get("name") else ""
if label.is_empty():
continue
var p: Vector2 = _center_to_canvas(river.get("center"))
draw_string(font, p, label, HORIZONTAL_ALIGNMENT_CENTER, -1, fs, COLOR_FEATURE_LABEL)
func _draw_pois(markers: Dictionary) -> void:
var pois: Array = markers.get("pois", [])
for p: Dictionary in pois:
if bool(p.get("gate_terminal", false)):
continue # drawn by gate_markers layer
var pos: Vector2 = _poi_pos(p)
_draw_diamond(pos, 4.0, COLOR_POI)
func _draw_gate_markers(markers: Dictionary) -> void:
# Gates show up in both pois[] (kind="gate") and cities[].gate_terminal
for p: Dictionary in markers.get("pois", []):
if not bool(p.get("gate_terminal", false)):
continue
_draw_diamond(_poi_pos(p), 6.0, COLOR_GATE)
for c: Dictionary in markers.get("cities", []):
if not bool(c.get("gate_terminal", false)):
continue
var pos: Vector2 = viewer.city_canvas_pos(c)
draw_arc(pos, 9.0, 0.0, TAU, 18, COLOR_GATE, 1.2, true)
func _draw_cities(markers: Dictionary) -> void:
var cities: Array = markers.get("cities", [])
if cities.is_empty():
return
var font := ThemeDB.fallback_font
# Compare by stable key, not Dictionary.== — deep equality was O(fields)
# per city per redraw (review #13). `name` is unique per body in the
# D-191 §8 schema; fall back to `body_id` or the hash of the dict for
# un-named markers so the comparison still works in transitional data.
var hovered_key: String = _city_key(viewer.get_hovered_city())
var selected_key: String = _city_key(viewer.get_selected_city())
for c: Dictionary in cities:
var pos: Vector2 = viewer.city_canvas_pos(c)
var tier: int = int(c.get("population_tier", 1))
var r: float = 2.5 + float(tier) * 0.8
var key: String = _city_key(c)
var is_selected: bool = not selected_key.is_empty() and key == selected_key
var is_hovered: bool = not hovered_key.is_empty() and key == hovered_key
var col: Color = COLOR_CITY
if is_selected:
col = COLOR_CITY_SELECTED
elif is_hovered:
col = COLOR_CITY_HOVER
draw_circle(pos, r + 1.0, Color(0.0, 0.0, 0.0, 0.55))
draw_circle(pos, r, col)
var name_str: String = c.get("name", "") if c.get("name") else ""
if not name_str.is_empty() and (tier >= 3 or is_hovered or is_selected):
var lcolor: Color = (
COLOR_CITY_HOVER if is_hovered or is_selected else Color(0.88, 0.90, 0.96, 0.85)
)
draw_string(
font,
pos + Vector2(r + 2.0, r * 0.4),
name_str,
HORIZONTAL_ALIGNMENT_LEFT,
-1,
8,
lcolor
)
static func _city_key(city: Dictionary) -> String:
if city.is_empty():
return ""
var name_val: Variant = city.get("name")
if name_val != null and not str(name_val).is_empty():
return "n:" + str(name_val)
var id_val: Variant = city.get("city_id")
if id_val != null and not str(id_val).is_empty():
return "i:" + str(id_val)
# Last resort: positional key from pos/lat/lon so two unnamed cities at
# different coordinates still compare unequal.
var pos: Variant = city.get("pos")
if pos != null:
return "p:" + str(pos)
if city.has("lat") and city.has("lon"):
return "ll:%s:%s" % [city["lat"], city["lon"]]
return "h:%d" % city.hash()
# =============================================================================
# Province boundaries (D-205, #927)
# =============================================================================
func _draw_province_boundaries(markers: Dictionary) -> void:
var provinces: Array = markers.get("provinces", [])
if provinces.is_empty():
draw_rect(
Rect2(
Vector2.ZERO,
Vector2(
viewer.get_heightmap_texture().get_width(),
viewer.get_heightmap_texture().get_height()
)
),
COLOR_POLITICAL
)
return
for prov: Dictionary in provinces:
var path: Array = prov.get("path", [])
if path.size() < 3:
continue
var points: PackedVector2Array = _province_path_to_canvas(path)
if points.size() >= 3:
draw_colored_polygon(points, COLOR_PROVINCE_FILL)
draw_polyline(points, COLOR_PROVINCE_BORDER, PROVINCE_BORDER_WIDTH, true)
func _province_path_to_canvas(path: Array) -> PackedVector2Array:
var out: PackedVector2Array = PackedVector2Array()
for pt: Variant in path:
if pt is Array and pt.size() >= 2:
out.append(viewer.grid_to_canvas(Vector2(float(pt[1]), float(pt[0]))))
return out
# =============================================================================
# Overlay placeholders (populated by server side signals eventually)
# =============================================================================
func _draw_population_density(markers: Dictionary) -> void:
# Placeholder: soft blobs at city positions scaled by population_tier
for c: Dictionary in markers.get("cities", []):
var pos: Vector2 = viewer.city_canvas_pos(c)
var tier: int = int(c.get("population_tier", 1))
var r: float = 12.0 + float(tier) * 6.0
draw_circle(pos, r, COLOR_POP_HEAT)
# Review #3: derive overlays from existing cities[] fields. The original
# code read production_zones / shadow_zones / corp_presence from keys that
# are not in the D-191 §8 markers schema, so toggling was a silent no-op.
# When the server schema lands with explicit zone polygons we can promote
# these back to dedicated arrays; until then the MVP reads what's there.
func _draw_production_zones(markers: Dictionary) -> void:
for city: Dictionary in markers.get("cities", []):
var func_id: String = str(city.get("primary_function", "")).to_lower()
if not PRODUCTION_FUNCTIONS.has(func_id):
continue
var pos: Vector2 = viewer.city_canvas_pos(city)
var tier: int = int(city.get("population_tier", 1))
var radius: float = 10.0 + float(tier) * 4.0
draw_circle(pos, radius, COLOR_PRODUCTION)
func _draw_shadow_economy(markers: Dictionary) -> void:
# Broad bands around cities outside the Commission's reach. Intentionally
# soft + overlapping — D-181 treats shadow zones as "broad bands", not
# precise polygons.
for city: Dictionary in markers.get("cities", []):
var commission: bool = bool(city.get("commission_presence", false))
if commission:
continue
var pos: Vector2 = viewer.city_canvas_pos(city)
var tier: int = int(city.get("population_tier", 1))
var radius: float = 18.0 + float(tier) * 6.0
draw_circle(pos, radius, COLOR_SHADOW)
func _draw_corp_presence(markers: Dictionary) -> void:
# Tier 1 corp presence — keyed off the Commission-presence flag on cities
# until D-191 §8 schema explicitly lists per-corp dots.
for city: Dictionary in markers.get("cities", []):
if not bool(city.get("commission_presence", false)):
continue
var pos: Vector2 = viewer.city_canvas_pos(city)
draw_rect(Rect2(pos - Vector2(3, 3), Vector2(6, 6)), COLOR_CORP)
# =============================================================================
# Helpers
# =============================================================================
# =============================================================================
# Generation-cascade overlays (#960, D-225)
# =============================================================================
## Map a Layer-1 [row, col] position (in the Layer1Output working grid) onto the
## displayed heightmap texture. Uses the grid dims FROM THE DATA — NOT
## viewer.grid_to_canvas, whose _grid_w/_grid_h is the markers.json coordinate
## space. Conflating the two scaled the overlays off by the downsample ratio (#960).
func _gen_pos(rc: Variant) -> Vector2:
return Vector2(float(rc[1]) / _gen_grid_w * _gen_tex_w, float(rc[0]) / _gen_grid_h * _gen_tex_h)
## District morphology grid (T-1046, D-226). A coarse `cols × rows` area fill —
## each cell coloured by its MorphologyZone discriminant (D-239 §6), semi-
## transparent so the heightmap reads through. Planetary-scale map view (NOT the
## 2 km on-demand districts, which are Phase 5 in-world).
func _draw_gen_district(grid: Dictionary, tex_w: float, tex_h: float) -> void:
var cols: int = int(grid.get("cols", 0))
var rows: int = int(grid.get("rows", 0))
if cols <= 0 or rows <= 0:
return
var morphology: Variant = grid.get("morphology")
if not morphology is PackedByteArray:
return
var cw: float = tex_w / float(cols)
var ch: float = tex_h / float(rows)
var n: int = morphology.size()
for ry in range(rows):
for rx in range(cols):
var i: int = ry * cols + rx
if i >= n:
continue
# +0.5 overdraw avoids hairline seams between adjacent cells.
draw_rect(
Rect2(rx * cw, ry * ch, cw + 0.5, ch + 0.5),
AtlasOverlayColors.morphology_color(int(morphology[i]))
)
## Region climate grid (T-1118, D-243 SS"region"). A coarse `cols x rows` area
## fill mirroring _draw_gen_district's SELF-CONTAINED coordinate mapping above
## (dims come from the layer dict itself, cw/ch = tex/cols|rows) — this is a
## dense row-major grid keyed by its OWN cols/rows, NOT a Layer-1 point
## position, so it does NOT use _gen_pos()/_gen_grid_w (that path is for
## Layer-1 river/basin/attractor points and the L2/L3 graphs, which share one
## working-grid coordinate space; the region grid is its own dense array with
## its own extent, exactly like district_grid).
##
## Channel: mean_temp_dc only (season/weather/moisture deferred to a future
## tooltip/variant overlay per the ticket scope — D-226 "one visual channel
## first"). Airless-body cells (REGION_TEMP_NONE_DC sentinel) are skipped
## entirely, not colored — see the REGION_TEMP_NONE_DC comment above.
func _draw_gen_region_grid(grid: Dictionary, tex_w: float, tex_h: float) -> void:
var cols: int = int(grid.get("cols", 0))
var rows: int = int(grid.get("rows", 0))
if cols <= 0 or rows <= 0:
return
var mean_temp_dc: Variant = grid.get("mean_temp_dc")
if not (mean_temp_dc is Array or mean_temp_dc is PackedByteArray):
return
var cw: float = tex_w / float(cols)
var ch: float = tex_h / float(rows)
var n: int = mean_temp_dc.size()
for ry in range(rows):
for rx in range(cols):
var i: int = ry * cols + rx
if i >= n:
continue
var temp_dc: int = int(mean_temp_dc[i])
if temp_dc == REGION_TEMP_NONE_DC:
continue # airless — no temperature to show
# +0.5 overdraw avoids hairline seams between adjacent cells.
draw_rect(
Rect2(rx * cw, ry * ch, cw + 0.5, ch + 0.5),
AtlasOverlayColors.region_temp_color(temp_dc)
)
func _draw_gen_rivers(layer1: Dictionary) -> void:
var rn: Variant = layer1.get("river_network")
if not rn is Dictionary:
return
for c: Variant in rn.get("river_cells", []):
if c is Array and c.size() >= 2:
draw_circle(_gen_pos(c), 1.2, COLOR_GEN_RIVER)
for cf: Variant in rn.get("confluences", []):
if cf is Array and cf.size() >= 2:
draw_circle(_gen_pos(cf), 3.0, COLOR_GEN_RIVER)
for m: Variant in rn.get("mouths", []):
if m is Array and m.size() >= 2:
var p: Vector2 = _gen_pos(m)
# Double ring = sea terminus (high-value anchor).
draw_arc(p, 5.0, 0.0, TAU, 18, COLOR_GEN_MOUTH, 1.5)
var halo := Color(COLOR_GEN_MOUTH.r, COLOR_GEN_MOUTH.g, COLOR_GEN_MOUTH.b, 0.30)
draw_arc(p, 8.0, 0.0, TAU, 22, halo, 1.0)
func _draw_gen_basins(layer1: Dictionary) -> void:
for b: Variant in layer1.get("drainage_basins", []):
if not b is Dictionary:
continue
var boundary: Array = b.get("boundary", [])
var pts: PackedVector2Array = PackedVector2Array()
for pt: Variant in boundary:
if pt is Array and pt.size() >= 2:
pts.append(_gen_pos(pt))
if pts.size() < 2:
continue
if pts.size() >= 3:
draw_colored_polygon(pts, COLOR_GEN_BASIN_FILL)
var loop: PackedVector2Array = pts.duplicate()
loop.append(pts[0])
draw_polyline(loop, COLOR_GEN_BASIN_LINE, 0.8, true)
func _draw_gen_attractors(layer1: Dictionary) -> void:
for a: Variant in layer1.get("attractors", []):
if not a is Dictionary:
continue
var strength: float = float(a.get("strength", 0.0))
if strength < GEN_ATTRACTOR_MIN_STRENGTH:
continue
var pos_rc: Variant = a.get("position")
if not pos_rc is Array or pos_rc.size() < 2:
continue
var size: float = 5.0 + strength * 4.0
var color: Color = AtlasOverlayColors.sub_biome_color(str(a.get("sub_biome", "")))
_draw_attractor_shape(str(a.get("attractor_type", "")), _gen_pos(pos_rc), size, color)
## Attractor type → marker shape (Araminta's vocabulary, 7 types).
func _draw_attractor_shape(atype: String, pos: Vector2, size: float, color: Color) -> void:
match atype:
"RiverMouth":
draw_circle(pos, size, color)
"CoastalAccess":
draw_arc(pos, size, 0.0, TAU, 18, color, 1.5)
draw_circle(pos, size * 0.5, color)
"RiverCrossing":
_draw_diamond(pos, size, color)
"ValleyFloor":
_draw_triangle(pos, size, color, true)
"PassEntrance":
_draw_triangle(pos, size, color, false)
"LakeShore":
draw_arc(pos, size, 0.0, TAU, 18, color, 1.5)
"PlainCenter":
draw_rect(Rect2(pos - Vector2(size, size) * 0.7, Vector2(size, size) * 1.4), color)
_:
draw_circle(pos, size, color)
func _draw_triangle(pos: Vector2, size: float, color: Color, point_down: bool) -> void:
var dir: float = 1.0 if point_down else -1.0
var pts: PackedVector2Array = PackedVector2Array(
[
pos + Vector2(0.0, size * dir),
pos + Vector2(-size, -size * dir),
pos + Vector2(size, -size * dir),
]
)
draw_colored_polygon(pts, color)
# =============================================================================
# Generation-cascade overlays — L2 roads / L3 settlements (T-960, D-225)
# =============================================================================
## Inter-settlement road/rail graph (RoadGraphLayer — D-211, T-1038). Edges
## colored by MaintenanceAuthority; rail vs road told apart by line style/
## width (dashed + thin = rail, solid + wider = road) rather than a second
## color axis. Junction markers sit at Settlement nodes with 3+ incident
## edges — RoadGraphLayer trims `degree`/`length_cells` as server-internal
## bookkeeping (dudley-atlas-server contract, 2026-07-14), so degree is
## derived here from the edge endpoints instead of read off the node.
func _draw_gen_roads(road_graph: Dictionary) -> void:
var edges: Array = road_graph.get("edges", [])
var degree_by_index: Dictionary = {}
for e: Variant in edges:
if not e is Dictionary:
continue
var from_i: int = int(e.get("from", -1))
var to_i: int = int(e.get("to", -1))
degree_by_index[from_i] = int(degree_by_index.get(from_i, 0)) + 1
degree_by_index[to_i] = int(degree_by_index.get(to_i, 0)) + 1
var path: Array = e.get("path", [])
if path.size() < 2:
continue
var points: PackedVector2Array = PackedVector2Array()
for pt: Variant in path:
if pt is Array and pt.size() >= 2:
points.append(_gen_pos(pt))
if points.size() < 2:
continue
var color: Color = AtlasOverlayColors.road_authority_color(str(e.get("maintenance", "")))
if bool(e.get("is_rail", false)):
_draw_dashed_polyline(points, color, 1.1)
else:
draw_polyline(points, color, 1.6, true)
var nodes: Array = road_graph.get("nodes", [])
for i: int in range(nodes.size()):
var n: Variant = nodes[i]
if not n is Dictionary:
continue
# Mirrors the server's own RoadGraph::high_connectivity_junctions()
# filter (Settlement kind only — a Waypoint sits mid-edge and
# structurally can't exceed degree 2).
if str(n.get("kind", "")) != "Settlement":
continue
if int(degree_by_index.get(i, 0)) < ROAD_JUNCTION_MIN_DEGREE:
continue
var pos_rc: Variant = n.get("position")
if not pos_rc is Array or pos_rc.size() < 2:
continue
_draw_junction_marker(_gen_pos(pos_rc), 3.5)
## Small hollow diamond — distinct from both the filled attractor diamond
## (RiverCrossing, larger + filled) and the filled settlement dot.
func _draw_junction_marker(pos: Vector2, size: float) -> void:
var pts: PackedVector2Array = PackedVector2Array(
[
pos + Vector2(0, -size),
pos + Vector2(size, 0),
pos + Vector2(0, size),
pos + Vector2(-size, 0),
pos + Vector2(0, -size),
]
)
draw_polyline(pts, COLOR_ROAD_JUNCTION, 1.0, true)
## Settlement placements (SettlementLayer — D-211 Layer 3, T-955's
## CityPlacement, wired through the proxy for the first time on generated
## bodies). `is_capital` is authored (atlas_city_names.kind == 'capital'),
## not population-derived — capitals get a distinct STAR shape (D-226
## shape-encodes-identity); everything else is a circle. Size scales with
## `size_class` (Major/Standard/Minor — the same D-211 Tier A/B cutoffs
## already used for placement). `settlements` tolerates a bare Array too,
## defensively, though the confirmed shape is always {"settlements": [...]}.
func _draw_gen_settlements(settlements: Variant) -> void:
var entries: Array = []
if settlements is Array:
entries = settlements
elif settlements is Dictionary:
entries = settlements.get("settlements", [])
if entries.is_empty():
return
var font := ThemeDB.fallback_font
var show_all_labels: bool = viewer.get_view_zoom() >= SETTLEMENT_LABEL_MIN_ZOOM
for s: Variant in entries:
if not s is Dictionary:
continue
var pos_rc: Variant = s.get("position")
if not pos_rc is Array or pos_rc.size() < 2:
continue
var pos: Vector2 = _gen_pos(pos_rc)
var size_class: String = str(s.get("size_class", "Minor"))
var is_capital: bool = bool(s.get("is_capital", false))
var radius: float = float(SETTLEMENT_RADII.get(size_class, SETTLEMENT_RADIUS_DEFAULT))
if is_capital:
_draw_star(pos, radius + 2.0, COLOR_SETTLEMENT_CAPITAL)
else:
draw_circle(pos, radius + 1.0, Color(0.0, 0.0, 0.0, 0.55))
draw_circle(pos, radius, COLOR_SETTLEMENT)
var name_str: String = str(s.get("name", ""))
if name_str.is_empty():
continue
if is_capital or size_class == "Major" or show_all_labels:
draw_string(
font,
pos + Vector2(radius + 3.0, radius * 0.4),
name_str,
HORIZONTAL_ALIGNMENT_LEFT,
-1,
8,
COLOR_FEATURE_LABEL
)
func _draw_star(pos: Vector2, size: float, color: Color) -> void:
var pts: PackedVector2Array = PackedVector2Array()
for i in range(10):
var angle: float = -PI / 2.0 + i * PI / 5.0
var r: float = size if i % 2 == 0 else size * 0.42
pts.append(pos + Vector2(cos(angle), sin(angle)) * r)
draw_colored_polygon(pts, color)
# =============================================================================
# Generation-cascade overlays — L4 quarter footprints (T-1119, D-226 T-1112 amendment)
# =============================================================================
## Quarter-footprint aggregates (QuarterFootprintLayer — D-226 T-1112 amendment
## SS1/SS4), one density-scaled glyph per city_id anchored at the SAME position
## the L3 settlement dot already drew (`_gen_pos` on the settlement's
## `position` — quarter footprints carry no independent spatial position per
## the amendment, only a `city_id` join key). `entries` is keyed by city_id
## (BTreeMap<u64,_> on the wire, decodes to a Dictionary with int keys) and is
## a SUBSET of settlements — a placement can exist with no quarter entry yet
## (async per-city generation), so a missing city_id is silently skipped, not
## an error. `landmark_count`/`corridor_count` are tooltip-only per the D-226
## SS2 hard ceiling — never drawn here (city-click sidebar addition, out of
## this overlay's scope). Draw call MUST come from the same `settlements`
## payload gen_l3_settlements just drew, so this needs the raw settlements
## data threaded in from _draw() rather than re-fetching it here.
func _draw_gen_quarter_footprints(footprints: Variant, settlements: Variant) -> void:
var entries: Dictionary = {}
if footprints is Dictionary:
entries = footprints.get("entries", {})
if entries.is_empty():
return
var settlement_entries: Array = []
if settlements is Array:
settlement_entries = settlements
elif settlements is Dictionary:
settlement_entries = settlements.get("settlements", [])
if settlement_entries.is_empty():
return
var show_notch: bool = viewer.get_view_zoom() >= SETTLEMENT_LABEL_MIN_ZOOM
for s: Variant in settlement_entries:
if not s is Dictionary:
continue
var city_id: int = int(s.get("city_id", -1))
if city_id < 0 or not entries.has(city_id):
continue
var entry: Dictionary = entries[city_id]
var pos_rc: Variant = s.get("position")
if not pos_rc is Array or pos_rc.size() < 2:
continue
_draw_quarter_glyph(_gen_pos(pos_rc), entry, show_notch)
## One density-scaled square glyph. Below SETTLEMENT_LABEL_MIN_ZOOM, draws at
## minimum size with color only (the notch is illegible at a few px anyway,
## per the amendment); at/above it, full size with the dominant-type notch.
## Size/color/shape decisions live in AtlasOverlayColors (unit tested there
## directly via test_atlas_overlays.gd) — this function is just the
## draw_rect/notch calls.
func _draw_quarter_glyph(pos: Vector2, entry: Dictionary, show_notch: bool) -> void:
var density_pct: int = int(entry.get("density_avg_pct", 0))
var side: float = AtlasOverlayColors.quarter_glyph_size(density_pct, show_notch)
var color: Color = AtlasOverlayColors.quarter_glyph_color(density_pct)
var half: Vector2 = Vector2(side, side) * 0.5
draw_rect(Rect2(pos - half, Vector2(side, side)), color)
if not show_notch:
return
var district_type: String = str(entry.get("dominant_district_type", ""))
_draw_quarter_notch(pos, half, AtlasOverlayColors.quarter_notch_kind(district_type))
## Draws the notch glyph for a resolved AtlasOverlayColors.quarter_notch_kind() result.
func _draw_quarter_notch(pos: Vector2, half: Vector2, kind: String) -> void:
var notch_color := Color(0.08, 0.08, 0.08, 0.55)
match kind:
"commercial":
# Corner tab, top-right.
var tab: float = half.x * 0.7
draw_rect(Rect2(pos + Vector2(half.x - tab, -half.y), Vector2(tab, tab)), notch_color)
"industrial":
# Corner tab, bottom-right.
var tab: float = half.x * 0.7
draw_rect(
Rect2(pos + Vector2(half.x - tab, half.y - tab), Vector2(tab, tab)), notch_color
)
"administrative":
# Small diamond cutout, center (civic/landmark read).
_draw_diamond(pos, half.x * 0.45, notch_color)
_:
pass # plain square — mixed / no clear dominant
func _path_to_canvas(path: Array) -> PackedVector2Array:
var out: PackedVector2Array = PackedVector2Array()
for pt: Variant in path:
if pt is Array and pt.size() >= 2:
out.append(viewer.grid_to_canvas(Vector2(float(pt[0]), float(pt[1]))))
return out
func _center_to_canvas(center: Variant) -> Vector2:
# Legacy markers.json encodes center as [row, col]
if center is Array and center.size() >= 2:
return viewer.grid_to_canvas(Vector2(float(center[1]), float(center[0])))
return Vector2.ZERO
func _poi_pos(poi: Dictionary) -> Vector2:
if poi.has("pos") and poi["pos"] is Array and poi["pos"].size() >= 2:
return viewer.grid_to_canvas(Vector2(float(poi["pos"][0]), float(poi["pos"][1])))
return _center_to_canvas(poi.get("center"))
func _draw_diamond(pos: Vector2, size: float, color: Color) -> void:
var pts: PackedVector2Array = PackedVector2Array(
[
pos + Vector2(0, -size),
pos + Vector2(size, 0),
pos + Vector2(0, size),
pos + Vector2(-size, 0),
]
)
draw_colored_polygon(pts, color)
func _draw_dashed_polyline(points: PackedVector2Array, color: Color, width: float) -> void:
if points.size() < 2:
return
# Simple per-segment dashing — good enough for the MVP.
for i: int in range(points.size() - 1):
var a: Vector2 = points[i]
var b: Vector2 = points[i + 1]
var seg: Vector2 = b - a
var seg_len: float = seg.length()
if seg_len <= 0.001:
continue
var dir: Vector2 = seg / seg_len
var t: float = 0.0
while t < seg_len:
var t_end: float = minf(t + RAIL_DASH_ON, seg_len)
draw_line(a + dir * t, a + dir * t_end, color, width, true)
t = t_end + RAIL_DASH_OFF
File diff suppressed because it is too large Load Diff
@@ -264,13 +264,29 @@ func _active_toggle_overlay() -> String:
## < 0 ascends. Cursor-anchored (D-255(a)/Stig round-1 §2): the world point
## under the cursor becomes the new step's request center, so the player's
## point of interest stays put across the crossing.
##
## **Hard full-zoom-out reset (D-255(a), PR #203 review — Hoshe finding 2):**
## scrolling OUT while ALREADY at rung 0 (Global) is the "past the top of the
## ladder" gesture — `scroll_step()` clamps the rung INDEX at 0 (there is no
## rung -1 to request), but the CANONICAL Global frame is more than just "rung
## 0" — it is rung 0 AT THE UN-PANNED CENTER (world_center == ZERO, view_offset
## == ZERO). A player who has panned around the Global canvas and then scrolls
## out again expects the HARD reset the retired viewer's own
## _maybe_reset_to_canonical_frame() provided (Jeroen's explicit HARD
## condition), not a silent no-op. Detected here (not inside
## StepCanvasTransport.scroll_step(), which is deliberately a pure
## index-clamp with no view-state concept) by checking whether ANY view
## drift exists at rung 0 when an ascend gesture arrives with nowhere further
## to ascend.
func _scroll_rung(direction: int, cursor_local: Vector2) -> void:
var new_index: int = StepCanvasTransport.scroll_step(_rung_index, direction)
if new_index == _rung_index:
if direction < 0 and _rung_index == 0 and _is_global_view_drifted():
_reset_to_global()
return
var cursor_world: Vector2 = StepCanvasTransport.canvas_local_to_world_m(
cursor_local - _view_offset, _world_center, _held_rung, _held_extent
)
var new_index: int = StepCanvasTransport.scroll_step(_rung_index, direction)
if new_index == _rung_index:
return
_rung_index = new_index
_held_rung = StepCanvasTransport.rung_at_index(_rung_index)
_world_center = cursor_world if _held_rung != StepCanvasTransport.RUNG_GLOBAL else Vector2.ZERO
@@ -280,10 +296,17 @@ func _scroll_rung(direction: int, cursor_local: Vector2) -> void:
queue_redraw()
## 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.
func _is_global_view_drifted() -> bool:
return _world_center != Vector2.ZERO or _view_offset != Vector2.ZERO
## Hard reset to the Global opener (D-255(a): "a hard full-zoom-out reset to
## the canonical Global body-surface frame").
func _reset_to_global() -> void:
if _rung_index == 0:
if _rung_index == 0 and not _is_global_view_drifted():
return
_rung_index = 0
_held_rung = StepCanvasTransport.RUNG_GLOBAL