fix(ui): latitude-inverse denominator + reticle city-hover gate (PR #187 findings)
Hoshe's blocking find: district_pos_at divided the latitude axis by tex_h where the server forward map uses ta.h.saturating_sub(1) (district_profile.rs:1436; ground-truth inverse aliveness_probe.rs:511) — every off-equator click descended into the wrong district, up to ~20km drift, live-repro'd at 10.2km. Fixed to /(tex_h - 1.0) with the reference implementation's degenerate-texture guard; verified against the repro (row 50: old formula recovered 41, fixed recovers 50 exact). Columns were already correct (longitude wraps — the asymmetry the docstring documented but the code didn't implement). Regression tests transcribe the server forward formula (source cited) and round-trip three off-equator rows — one per hemisphere plus near-pole — asserting EXACT recovery; a dedicated drift guard is framed as Tyre's C2 (a client-side twin of a server mapping owns its own round-trip proof). Root-caused the coverage hole: the old equator test sampled tex_h*0.5 believing it was the equator pixel — the true equator is (tex_h-1)*0.5, so the test never verified what it claimed; corrected with the why documented. Araminta's find: the descend reticle drew unconditionally on hover — over a city the marker flared white (city-click signal) while the reticle+extent label promised descent, though the click correctly resolved to city data. Extracted _should_draw_descend_reticle() with the missing _hovered_city.is_empty() clause; three state tests pin shows-over-map / hides-over-city / hides-when-idle. test_atlas_descend_entry 32/32; adjacent clusters no ripple; gdlint clean (atlas_viewer.gd at exactly the 1000-line cap). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -80,17 +80,30 @@ static func reticle_label(center: Vector2) -> Dictionary:
|
||||
## 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)) * tex_h
|
||||
## which is a linear scaling of the same world-metre fraction 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.
|
||||
## 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:
|
||||
@@ -103,5 +116,12 @@ static func district_pos_at(
|
||||
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 row: int = roundi(((canvas_pt.y / tex_h) - 0.5) * district_rows_half * 2.0)
|
||||
# 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 * district_rows_half * 2.0)
|
||||
return Vector2i(col, row)
|
||||
|
||||
@@ -16,8 +16,7 @@ extends Control
|
||||
## - 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().
|
||||
## - T-1138: view is FIXED (set_view() is capture-API-only); descent screen has the reticle math.
|
||||
## Navigation: Hover reticle · Click descend · Click city data (wins) · R reset(no-op) · Esc back
|
||||
## Navigation (view FIXED, T-1138): hover reticle (hidden/city) · click descend/city · esc back
|
||||
|
||||
signal back_pressed
|
||||
signal economics_link_requested(system_id: String)
|
||||
@@ -645,13 +644,15 @@ func screen_to_canvas(screen_point: Vector2) -> Vector2:
|
||||
|
||||
func _draw() -> void:
|
||||
draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG)
|
||||
if _hover_active and _heightmap_texture != null:
|
||||
if _should_draw_descend_reticle():
|
||||
_draw_descend_reticle()
|
||||
|
||||
|
||||
## T-1138 NOT-TO-SCALE reticle (§5's open design point). Draw calls only —
|
||||
## geometry/rationale in atlas_descend_geometry.gd (draw_line must run on `self`).
|
||||
func _draw_descend_reticle() -> 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()
|
||||
|
||||
|
||||
func _draw_descend_reticle() -> void: # draw calls only — geometry in atlas_descend_geometry.gd
|
||||
var center: Vector2 = _hover_screen_pos
|
||||
for seg: Array in AtlasDescendGeometry.reticle_segments(center):
|
||||
draw_line(seg[0], seg[1], AtlasDescendGeometry.COLOR_DESCEND_RETICLE, 1.5)
|
||||
|
||||
Reference in New Issue
Block a user